coderef

Crunchyroll

Crunchyroll's iOS app is a cinema for anime. The entire experience is built on a true-black OLED canvas (#000000) so that full-bleed key-art — the painted, saturated, character-forward poster of each show — can bleed edge-to-edge and…
DownloadRaw
Crunchyroll

Build with the Crunchyroll look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
dark
Typefaces
Lato
Sections
7
Length
4,275 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Crunchyroll

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

Crunchyroll (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Crunchyroll’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the resume-aware episode list, the sliding-underline segmented control, motion, and haptics.

Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (true-black OLED canvas, single Crunchyroll Orange accent, full-bleed key-art with a black scrim, the resume progress bar) while making everything idiomatic Android — NavigationBar instead of a UITabBar, Brush.verticalGradient for the scrim, AnimatedContent/animateDpAsState for the segmented underline, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for key-art and stills. No color extraction — Crunchyroll’s palette is a fixed dark system, so Palette is not needed. Crunchyroll is dark-only; there is no light scheme.

1. Color Tokens

// ui/theme/CrunchyrollColors.kt
import androidx.compose.ui.graphics.Color

object CrunchyrollColors {
    // Canvas & Surfaces (dark-only)
    val Canvas   = Color(0xFF000000) // true black OLED
    val Surface1 = Color(0xFF16171A) // sheets / modals
    val Surface2 = Color(0xFF23252B) // cards / press / search field
    val Surface3 = Color(0xFF2E3035) // progress track / chips
    val Divider  = Color(0xFF2A2C31)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFA0A0A0)
    val TextTertiary  = Color(0xFF6A6C70)
    val TextOnScrim   = Color(0xFFD8D8D8)

    // Brand & status
    val Orange        = Color(0xFFF47521) // the ONLY accent
    val OrangePressed = Color(0xFFD8610F)
    val OrangeTint    = Color(0xFFFF8C42)
    val PremiumGold   = Color(0xFFFFC107) // Premium badge / lock only
    val PremiumInk    = Color(0xFF1A1304) // text on gold
    val Simulcast     = Color(0xFF2BB673)
    val NewBlue       = Color(0xFF2A9DF4)
    val Error         = Color(0xFFE03E3E)
}

Wire it into a dark-only scheme. Crunchyroll has no light mode — always use the dark scheme; a light canvas breaks the OLED key-art floor.

// ui/theme/Theme.kt
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable

private val CrunchyrollScheme = darkColorScheme(
    primary        = CrunchyrollColors.Orange,        // the single accent
    onPrimary      = CrunchyrollColors.TextPrimary,
    background     = CrunchyrollColors.Canvas,
    onBackground   = CrunchyrollColors.TextPrimary,
    surface        = CrunchyrollColors.Surface1,
    onSurface      = CrunchyrollColors.TextPrimary,
    surfaceVariant = CrunchyrollColors.Surface2,
    outline        = CrunchyrollColors.Divider,
    error          = CrunchyrollColors.Error,
)

@Composable
fun CrunchyrollTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = CrunchyrollScheme,   // always dark — never switch on isSystemInDarkTheme()
        typography  = CrunchyrollTypography,
        content     = content,
    )

2. Typography

Crunchyroll’s product face is Lato, leaning hard on the 900 (Black) weight for hero titles, CTAs, and badges. Drop the TTFs in res/font/. Lato is SIL OFL — free to ship. Body 400; titles/CTAs/badges 900.

// ui/theme/CrunchyrollType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val Lato = FontFamily(
    Font(R.font.lato_regular, FontWeight.Normal),
    Font(R.font.lato_bold,    FontWeight.Bold),
    Font(R.font.lato_black,   FontWeight.Black),
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object CrunchyrollText {
    val ScreenTitle   = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
    val HeroTitle     = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 28.sp, lineHeight = 30.sp, letterSpacing = (-0.5).sp)
    val SectionHeader = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 22.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
    val RowHeader     = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 18.sp, lineHeight = 22.sp)
    val Body          = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
    val EpisodeTitle  = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 20.sp)
    val Synopsis      = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
    val Meta          = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
    val ButtonLabel   = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.3.sp)
    val Segmented     = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 14.sp, lineHeight = 14.sp)
    val Badge         = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.4.sp)
    val Tab           = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Caption       = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val CrunchyrollTypography = Typography(
    headlineLarge  = CrunchyrollText.ScreenTitle,
    headlineMedium = CrunchyrollText.HeroTitle,
    titleMedium    = CrunchyrollText.EpisodeTitle,
    bodyMedium     = CrunchyrollText.Body,
    labelSmall     = CrunchyrollText.Tab,
)

3. Signature Components

Anime Detail Hero (full-bleed key-art + scrim)

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun AnimeHero(
    keyArtUrl: String,
    title: String,
    meta: String,                 // "★ 4.9 · 2024 · TV-14 · Action"
    isSimulcast: Boolean,
    isPremium: Boolean,
) {
    val screenH = LocalConfiguration.current.screenHeightDp.dp
    Box(Modifier.fillMaxWidth().height(screenH * 0.6f)) {
        AsyncImage(
            model = keyArtUrl,
            contentDescription = title,
            modifier = Modifier.fillMaxSize(),     // full-bleed, no corner radius
            contentScale = ContentScale.Crop,
        )
        // Hero-to-black scrim — the signature depth cue
        Box(
            Modifier
                .fillMaxSize()
                .background(
                    Brush.verticalGradient(
                        0.0f to Color.Transparent,
                        0.55f to Color.Black.copy(alpha = 0.55f),
                        1.0f to Color.Black,
                    )
                )
        )
        Column(
            Modifier
                .align(Alignment.BottomStart)
                .padding(horizontal = 20.dp)
                .padding(bottom = 18.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                if (isSimulcast) ContentBadge(BadgeKind.Simulcast)
                if (isPremium)   ContentBadge(BadgeKind.Premium)
            }
            Text(title, style = CrunchyrollText.HeroTitle, color = CrunchyrollColors.TextPrimary, maxLines = 2)
            Text(meta,  style = CrunchyrollText.Meta,      color = CrunchyrollColors.TextOnScrim)
        }
    }
}

Primary CTA (“Start Watching” / “Resume”)

import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon

@Composable
fun WatchButton(label: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Button(
        onClick = onClick,
        interactionSource = interaction,
        shape = RoundedCornerShape(8.dp),
        colors = ButtonDefaults.buttonColors(
            containerColor = if (pressed) CrunchyrollColors.OrangePressed else CrunchyrollColors.Orange,
            contentColor = CrunchyrollColors.TextPrimary,
        ),
        modifier = Modifier.fillMaxWidth().height(52.dp),
    ) {
        Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp))
        Spacer(Modifier.width(10.dp))
        Text(label, style = CrunchyrollText.ButtonLabel)
    }
}

Episode Row (resume-aware)

import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow

@Composable
fun EpisodeRow(
    number: String,           // "E1"
    stillUrl: String,
    title: String,
    synopsis: String,
    progress: Float,          // 0f..1f
) {
    Column {
        Row(
            Modifier.fillMaxWidth().padding(vertical = 12.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(Modifier.size(112.dp, 64.dp).clip(RoundedCornerShape(6.dp)).background(CrunchyrollColors.Surface2)) {
                AsyncImage(model = stillUrl, contentDescription = null,
                    modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
                Box(
                    Modifier
                        .align(Alignment.TopStart).padding(4.dp)
                        .clip(RoundedCornerShape(3.dp))
                        .background(Color.Black.copy(alpha = 0.7f))
                        .padding(horizontal = 6.dp, vertical = 2.dp)
                ) { Text(number, style = CrunchyrollText.Badge, color = CrunchyrollColors.TextPrimary) }

                if (progress > 0f) {
                    Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(3.dp)
                        .background(Color.White.copy(alpha = 0.25f))) {
                        Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(CrunchyrollColors.Orange))
                    }
                }
            }
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
                Text(title, style = CrunchyrollText.EpisodeTitle, color = CrunchyrollColors.TextPrimary)
                Text(synopsis, style = CrunchyrollText.Synopsis, color = CrunchyrollColors.TextSecondary,
                    maxLines = 2, overflow = TextOverflow.Ellipsis)
            }
        }
        HorizontalDivider(color = CrunchyrollColors.Divider, thickness = 1.dp)
    }
}

Content Badge (Simulcast / Premium / New Episode)

enum class BadgeKind { Simulcast, Premium, NewEpisode }

@Composable
fun ContentBadge(kind: BadgeKind) {
    val (bg, fg, label) = when (kind) {
        BadgeKind.Simulcast  -> Triple(CrunchyrollColors.Simulcast,   CrunchyrollColors.TextPrimary, "SIMULCAST")
        BadgeKind.Premium    -> Triple(CrunchyrollColors.PremiumGold, CrunchyrollColors.PremiumInk,  "PREMIUM")
        BadgeKind.NewEpisode -> Triple(CrunchyrollColors.NewBlue,     CrunchyrollColors.TextPrimary, "NEW EPISODE")
    }
    Box(
        Modifier
            .clip(RoundedCornerShape(4.dp))
            .background(bg)
            .padding(horizontal = 8.dp, vertical = 4.dp)
    ) { Text(label, style = CrunchyrollText.Badge, color = fg) }
}

Segmented Control (sliding orange underline)

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.ui.unit.Dp

@Composable
fun CRSegmented(items: List<String>, selected: Int, onSelect: (Int) -> Unit) {
    val tabWidths = remember { mutableStateMapOf<Int, Dp>() }
    val tabX = remember { mutableStateMapOf<Int, Dp>() }
    val density = LocalDensity.current

    val underlineX by animateDpAsState(tabX[selected] ?: 0.dp, tween(220), label = "underlineX")
    val underlineW by animateDpAsState(tabWidths[selected] ?: 0.dp, tween(220), label = "underlineW")

    Column {
        Row(horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier.padding(bottom = 10.dp)) {
            items.forEachIndexed { i, label ->
                Text(
                    label,
                    style = CrunchyrollText.Segmented,
                    color = if (i == selected) CrunchyrollColors.TextPrimary else CrunchyrollColors.TextSecondary,
                    modifier = Modifier
                        .clickable { onSelect(i) }
                        .onGloballyPositioned {
                            tabX[i] = with(density) { it.positionInParent().x.toDp() }
                            tabWidths[i] = with(density) { it.size.width.toDp() }
                        },
                )
            }
        }
        Box(Modifier.fillMaxWidth()) {
            HorizontalDivider(color = CrunchyrollColors.Divider, thickness = 1.dp)
            Box(
                Modifier
                    .offset(x = underlineX)
                    .width(underlineW)
                    .height(3.dp)
                    .clip(RoundedCornerShape(2.dp))
                    .background(CrunchyrollColors.Orange)
            )
        }
    }
}

4. Navigation

Crunchyroll has minimal chrome: a translucent floating top bar over the key-art and a 5-tab bottom strip. On Android, model the strip as a NavigationBar. There is no tint pill — active is just Crunchyroll Orange.

import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*

@Composable
fun CRBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = CrunchyrollColors.Canvas.copy(alpha = 0.95f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"      to Icons.Filled.Home,
            "Browse"    to Icons.Filled.GridView,
            "Watchlist" to Icons.Filled.Add,
            "Manga"     to Icons.Filled.MenuBook,
            "Profile"   to Icons.Filled.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
                label = { Text(label, style = CrunchyrollText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = CrunchyrollColors.Orange,     // the only accent
                    selectedTextColor   = CrunchyrollColors.Orange,
                    unselectedIconColor = CrunchyrollColors.TextTertiary,
                    unselectedTextColor = CrunchyrollColors.TextTertiary,
                    indicatorColor      = Color.Transparent,            // no Material pill
                ),
            )
        }
    }
}

The detail-screen top controls (back, share, cast) are not a solid TopAppBar — they float directly over the key-art at the status-bar inset, switching to a solid Canvas bar with the compact title once the hero title scrolls out.

5. Motion

Crunchyroll motion is cinematic and calm — 150–300ms ease-out, including a deliberate “lights down” fade-to-black before the player.

Moment Compose recipe
Segmented underline animateDpAsState for x + width, tween(220)
Card press animateFloatAsState scale → 0.97, tween(150)
“Lights down” → player fade detail alpha 1 → 0 tween(250), then navigate to the player
Progress fill on return animateFloatAsState 0 → watched, tween(400)
Hero parallax track LazyListState.firstVisibleItemScrollOffset; key-art Modifier.graphicsLayer { translationY = offset * 0.5f }
Top-bar reveal AnimatedVisibility crossfade of the solid Canvas bar over tween(200) once hero title scrolls out
Sheet present ModalBottomSheet default slide-up; scrim Color.Black.copy(alpha = 0.6f)
// The canonical "lights down" before playback
var detailAlpha by remember { mutableFloatStateOf(1f) }
val a by animateFloatAsState(detailAlpha, tween(250), label = "lightsDown") {
    if (it == 0f) navController.navigate("player/$episodeId")
}
Box(Modifier.graphicsLayer { alpha = a }) { /* detail content */ }

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft tick on Watchlist toggle and segment change; a stronger HapticFeedbackConstants.CONFIRM (via LocalView) on “Start Watching”.

6. Icons

Crunchyroll’s iconography is simple and stroked; the closest first-party set is androidx.compose.material:material-icons-extended.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house / house.fill Icons.Filled.Home
Browse (tab) square.grid.2x2 Icons.Filled.GridView
Watchlist (tab) plus Icons.Filled.Add
Manga (tab) book.fill Icons.Filled.MenuBook
Profile (tab) person.crop.circle Icons.Filled.AccountCircle
Play (CTA) play.fill Icons.Filled.PlayArrow
Add to Watchlist plus / checkmark Icons.Filled.Add / Icons.Filled.Check
Rate hand.thumbsup Icons.Filled.ThumbUp
Share square.and.arrow.up Icons.Filled.Share
Comments bubble.left Icons.Filled.ChatBubbleOutline
Cast airplayvideo Icons.Filled.Cast
Back chevron.left Icons.Filled.ArrowBack
Search magnifyingglass Icons.Filled.Search
Notifications bell Icons.Filled.Notifications
Premium lock lock.fill Icons.Filled.Lock
Download arrow.down.circle Icons.Filled.Download

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion + Brush gradients are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() and draw the key-art under the status bar (full-bleed). The canvas is true black so use light-content system bars permanently; the detail top controls must clear the camera cutout.
  • Dark-only: do not branch on isSystemInDarkTheme() or enable Material You dynamicColorScheme() — Crunchyroll has no light mode and no wallpaper-harmonized accent; the OLED black + single orange identity must hold.
  • Font scaling: sp honors the user’s font scale — keep it on hero title, section headers, body, synopsis, meta. Pin layout-sensitive text (badges, 10sp tab labels, segmented items, episode-number chip, progress-bar geometry) by deriving from dp or a fixed-fontScale CompositionLocalProvider.
  • TalkBack: label the hero “{title}, {meta}, Simulcast/Premium”; episode rows “Episode {n}, {title}, {percent} percent watched”; expose progress via Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) }.
  • Touch targets: Material guidance is 48.dp. The CTA is 52.dp (fine); give the 22.dp tab icons a 48.dp hit area; episode rows are full-row tappable (≥64.dp); segmented items get a 44.dp hit height.
  • Contrast: #FFFFFF on #000000 is maximal. The PREMIUM badge uses dark #1A1304 on gold #FFC107 to pass WCAG AA — never white-on-gold. #A0A0A0 meta on the scrim passes AA at 14sp.
  • Single-accent discipline is an a11y asset: orange is the only “actionable / resume” signal — never add a second accent; it would dilute the cue.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable hero parallax and skip the “lights down” fade (cut straight to the player); replace the segmented-underline slide with a Crossfade. Keep the progress fill static at the watched value.
  • Player: lock the video activity to landscape + immersive full-screen; precede it with the 250ms fade-to-black for the cinematic transition.
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses