coderef

YouTube Music

YouTube Music's iOS app is a dark-only, album-art-forward music environment built on a near-black canvas (#030303) that is deliberately darker than nearly every competitor (Spotify's #121212, Apple Music's #000000-on-blur, Amazon Music's…
DownloadRaw
YouTube Music

Build with the YouTube Music look.

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

Palette

At a glance

Theme
dark
Typefaces
System
Sections
7
Length
3,799 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · YouTube Music

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

YouTube Music (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports YouTube Music’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the immersive Now Playing + Song/Video toggle + up-next shelf, 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 (near-black #030303 canvas, art-glow backdrop, white play button, the Song/Video toggle, YT red as chrome-only) while making everything idiomatic Android — NavigationBar instead of a UITabBar, ModalBottomSheet for the queue, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for artwork. No Palette extraction — the backdrop is the blurred artwork itself, not a derived swatch. YT Music is dark-only; do not provide a light scheme.

1. Color Tokens

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

object YTMColors {
    // Canvas & Surfaces (dark — the only mode)
    val Canvas      = Color(0xFF030303) // near-black — darker than Spotify on purpose
    val Surface1    = Color(0xFF1F1F1F)
    val Surface2    = Color(0xFF272727)
    val MiniSurface = Color(0xFF282828)
    val ChipBg      = Color(0xFF2A2A2A)
    val Divider     = Color(0xFF303030)
    val TabBar      = Color(0xFF0A0A0A)

    // Brand
    val Red         = Color(0xFFFF0000) // chrome only — never the play button
    val RedPressed  = Color(0xFFCC0000)
    val ActionWhite = Color(0xFFFFFFFF) // the play surface + primary text

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFAAAAAA)
    val TextTertiary  = Color(0xFF717171)

    // Semantic
    val Success = Color(0xFF2BA640)
    val Error   = Color(0xFFFF4E45)
}

Wire it into a dark-only scheme. YT Music has no light mode on iOS; mirror that.

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

private val YTMScheme = darkColorScheme(
    primary        = YTMColors.ActionWhite, // primary action surface is white
    onPrimary      = YTMColors.Canvas,
    background      = YTMColors.Canvas,
    onBackground    = YTMColors.TextPrimary,
    surface         = YTMColors.Surface1,
    onSurface       = YTMColors.TextPrimary,
    surfaceVariant  = YTMColors.Surface2,
    outline         = YTMColors.Divider,
    error           = YTMColors.Error,
    secondary       = YTMColors.Red,        // brand chrome accent
)

@Composable
fun YTMTheme(content: @Composable () -> Unit) = MaterialTheme(
    colorScheme = YTMScheme, // always dark — never branch on isSystemInDarkTheme()
    typography  = YTMTypography,
    content     = content,
)

2. Typography (M3)

YouTube Music uses Roboto across every YouTube product — drop the TTFs in res/font/, never substitute. Roboto is also Android’s platform default, so this is the most native of the four music apps. Body 400, rows 500, headers/buttons 700, screen titles 900.

// ui/theme/YTMType.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 Roboto = FontFamily(
    Font(R.font.roboto_regular, FontWeight.Normal),
    Font(R.font.roboto_medium,  FontWeight.Medium),
    Font(R.font.roboto_bold,    FontWeight.Bold),
    Font(R.font.roboto_black,   FontWeight.Black),
)

object YTMText {
    val ScreenTitle = TextStyle(Roboto, fontWeight = FontWeight.Black,  fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.4).sp)
    val NowPlaying  = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val CardHeader  = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val Body        = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
    val RowTitle    = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp)
    val Subtitle    = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
    val Toggle      = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
    val Chip        = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 13.sp)
    val Timestamp   = TextStyle(Roboto, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 11.sp)
    val Eyebrow     = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.6.sp)
    val Tab         = TextStyle(Roboto, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val Button      = TextStyle(Roboto, fontWeight = FontWeight.Bold,   fontSize = 15.sp, lineHeight = 15.sp)
}

val YTMTypography = Typography(
    headlineLarge = YTMText.ScreenTitle,
    headlineMedium = YTMText.Section,
    titleMedium   = YTMText.CardHeader,
    bodyMedium    = YTMText.Body,
    labelSmall    = YTMText.Tab,
)

3. Signature Components

Immersive Now Playing (art-glow backdrop + art + meta)

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import coil.compose.AsyncImage

@Composable
fun NowPlaying(artUrl: String, track: String, artist: String) {
    Box(Modifier.fillMaxSize().background(YTMColors.Canvas)) {
        // Art-derived blurred backdrop glow (RenderEffect blur, API 31+; fallback below)
        AsyncImage(
            model = artUrl,
            contentDescription = null,
            modifier = Modifier
                .matchParentSize()
                .scale(1.4f)
                .blur(40.dp), // requires API 31+; for 24–30 pre-blur a downscaled bitmap
            contentScale = ContentScale.Crop,
        )
        Box(
            Modifier
                .matchParentSize()
                .background(
                    Brush.verticalGradient(
                        0.4f to Color.Transparent,
                        1f to YTMColors.Canvas,
                    )
                )
        )

        Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
            Spacer(Modifier.height(26.dp))
            AsyncImage(
                model = artUrl,
                contentDescription = "Album art",
                modifier = Modifier.size(232.dp).clip(RoundedCornerShape(8.dp)),
                contentScale = ContentScale.Crop,
            )
            Spacer(Modifier.height(22.dp))
            SongVideoToggle()
            Column(Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 22.dp)) {
                Text(track, style = YTMText.NowPlaying, color = YTMColors.TextPrimary)
                Spacer(Modifier.height(4.dp))
                Text(artist, style = YTMText.Subtitle, color = YTMColors.TextSecondary)
            }
        }
    }
}

Song / Video Toggle (Signature)

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun SongVideoToggle() {
    var mode by remember { mutableStateOf("song") }
    val haptics = LocalHapticFeedback.current

    Row(
        Modifier
            .clip(RoundedCornerShape(999.dp))
            .background(Color.White.copy(alpha = 0.08f))
            .padding(4.dp),
        horizontalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        listOf("song" to "Song", "video" to "Video").forEach { (key, label) ->
            val active = mode == key
            val bg by animateColorAsState(
                if (active) YTMColors.ActionWhite else Color.Transparent,
                tween(200), label = "toggleBg",
            )
            Box(
                Modifier
                    .clip(RoundedCornerShape(999.dp))
                    .background(bg)
                    .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                        mode = key
                    }
                    .padding(horizontal = 18.dp, vertical = 7.dp),
            ) {
                Text(label, style = YTMText.Toggle, color = if (active) YTMColors.Canvas else YTMColors.TextSecondary)
            }
        }
    }
}

Scrubber (white at rest, red while dragging)

import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp

@Composable
fun Scrubber() {
    var progress by remember { mutableFloatStateOf(0.42f) }
    var dragging by remember { mutableStateOf(false) }
    val haptics = LocalHapticFeedback.current

    Column {
        Canvas(
            Modifier
                .fillMaxWidth()
                .height(16.dp)
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { dragging = true },
                        onDragEnd = {
                            dragging = false
                            haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                        },
                        onDrag = { change, _ ->
                            progress = (change.position.x / size.width).coerceIn(0f, 1f)
                        },
                    )
                }
        ) {
            val cy = size.height / 2
            drawLine(Color.White.copy(alpha = 0.22f), Offset(0f, cy), Offset(size.width, cy), strokeWidth = 3.dp.toPx())
            val tint = if (dragging) YTMColors.Red else YTMColors.ActionWhite
            drawLine(tint, Offset(0f, cy), Offset(size.width * progress, cy), strokeWidth = 3.dp.toPx())
            if (dragging) drawCircle(YTMColors.Red.copy(alpha = 0.18f), 14.dp.toPx(), Offset(size.width * progress, cy))
            drawCircle(tint, (if (dragging) 8 else 6).dp.toPx(), Offset(size.width * progress, cy))
        }
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
            Text("1:48", style = YTMText.Timestamp, color = YTMColors.TextSecondary)
            Text("-2:34", style = YTMText.Timestamp, color = YTMColors.TextSecondary)
        }
    }
}

Transport Controls (white play button)

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton

@Composable
fun Transport() {
    var playing by remember { mutableStateOf(true) }
    Row(
        Modifier.fillMaxWidth().padding(horizontal = 32.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(Icons.Filled.SkipPrevious, "Previous", tint = YTMColors.TextPrimary, modifier = Modifier.size(22.dp))
        Icon(Icons.Filled.FastRewind, "Rewind", tint = YTMColors.TextPrimary, modifier = Modifier.size(26.dp))
        Box(
            Modifier.size(64.dp).clip(RoundedCornerShape(999.dp)).background(YTMColors.ActionWhite)
                .clickable { playing = !playing },
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                if (playing) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                contentDescription = if (playing) "Pause" else "Play",
                tint = YTMColors.Canvas, // black glyph on white — never red
                modifier = Modifier.size(26.dp),
            )
        }
        Icon(Icons.Filled.FastForward, "Forward", tint = YTMColors.TextPrimary, modifier = Modifier.size(26.dp))
        Icon(Icons.Filled.SkipNext, "Next", tint = YTMColors.TextPrimary, modifier = Modifier.size(22.dp))
    }
}

Up-Next Queue Shelf (ModalBottomSheet)

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpNextShelf(items: List<Pair<String, String>>, onDismiss: () -> Unit) {
    ModalBottomSheet(
        onDismissRequest = onDismiss,
        containerColor = YTMColors.Canvas,
        sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false),
    ) {
        Row(
            Modifier.fillMaxWidth().padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text("UP NEXT", style = YTMText.Eyebrow, color = YTMColors.TextSecondary)
            Icon(Icons.Filled.QueueMusic, "Queue", tint = YTMColors.TextSecondary, modifier = Modifier.size(20.dp))
        }
        LazyColumn {
            items(items) { (title, artist) ->
                Row(
                    Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
                    horizontalArrangement = Arrangement.spacedBy(10.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Box(Modifier.size(40.dp).clip(RoundedCornerShape(4.dp)).background(YTMColors.Surface2))
                    Column(Modifier.weight(1f)) {
                        Text(title, style = YTMText.RowTitle, color = YTMColors.TextPrimary)
                        Text(artist, style = YTMText.Subtitle, color = YTMColors.TextSecondary)
                    }
                    Icon(Icons.Filled.DragHandle, "Reorder", tint = YTMColors.TextTertiary, modifier = Modifier.size(18.dp))
                }
            }
        }
    }
}

4. Core Navigation

YT Music has minimal chrome: a 4-tab bottom strip (Home / Samples / Explore / Library) and an immersive Now Playing reached by tapping the mini-player. Active is pure white — no Material tint pill, no red.

import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults

@Composable
fun YTMBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = YTMColors.TabBar, tonalElevation = 0.dp) {
        val items = listOf(
            "Home" to Icons.Filled.Home,
            "Samples" to Icons.Filled.VideoLibrary,
            "Explore" to Icons.Filled.Search,
            "Library" to Icons.Filled.LibraryMusic,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = YTMText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = YTMColors.ActionWhite,   // white, not red
                    selectedTextColor = YTMColors.ActionWhite,
                    unselectedIconColor = YTMColors.TextTertiary,
                    unselectedTextColor = YTMColors.TextTertiary,
                    indicatorColor = Color.Transparent,          // no Material pill
                ),
            )
        }
    }
}

The mini-player sits directly above this bar (MiniSurface #282828, 8.dp corners, a 2.dp ActionWhite progress line at its bottom edge); tapping it pushes up the immersive Now Playing.

5. Motion

YT Music motion is purposeful and quick. Depth comes from the art glow, not Material elevation.

Moment Compose recipe
Song ↔ Video toggle active bg animateColorAsState(tween(200)); player body AnimatedContent cross-morph tween(250); never restart playback
Now Playing present mini-bar → full screen: AnimatedVisibility slideInVertically(tween(320)) from bottom
Up-next shelf ModalBottomSheet default spring + scrim; no custom timing needed
Scrubber drag thumb radius + color swap driven by dragging state, ~tween(80) in / tween(150) out
Art backdrop crossfade Crossfade(targetState = artUrl, animationSpec = tween(400))
Tab switch instant icon swap — no slide, no pill animation
Samples feed VerticalPager with snap, spring(dampingRatio = 0.85f) settle
// Song ↔ Video — cross-morph the player surface
AnimatedContent(
    targetState = mode,
    transitionSpec = { fadeIn(tween(250)) togetherWith fadeOut(tween(250)) },
    label = "songVideo",
) { m -> if (m == "song") SquareArtPlayer() else VideoPlayer16x9() }

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for the soft tick on toggle flip, scrubber touch-down/release, and queue-reorder commit. Track changes and auto-advance are silent.

6. Icons

Closest first-party set is androidx.compose.material:material-icons-extended. The block handle for queue reorder maps to DragHandle; the play button is white with a Canvas-colored glyph.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house.fill Icons.Filled.Home
Samples (tab) rectangle.stack.fill Icons.Filled.VideoLibrary
Explore (tab) magnifyingglass Icons.Filled.Search
Library (tab) books.vertical.fill Icons.Filled.LibraryMusic
Dismiss player chevron.down Icons.Filled.KeyboardArrowDown
Overflow ellipsis Icons.Filled.MoreVert
Play / Pause play.fill / pause.fill Icons.Filled.PlayArrow / Icons.Filled.Pause
Next / Previous forward.fill / backward.fill Icons.Filled.FastForward / Icons.Filled.FastRewind
Skip end / start forward.end.fill / backward.end.fill Icons.Filled.SkipNext / Icons.Filled.SkipPrevious
Queue / up-next list.bullet Icons.Filled.QueueMusic
Drag handle line.3.horizontal Icons.Filled.DragHandle
Shuffle shuffle Icons.Filled.Shuffle
Repeat repeat / repeat.1 Icons.Filled.Repeat / Icons.Filled.RepeatOne
Like hand.thumbsup.fill Icons.Filled.ThumbUp
Cast airplayaudio Icons.Filled.Cast
Download arrow.down.circle Icons.Filled.Download

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+). Modifier.blur() requires API 31+; on 24–30 the art-glow backdrop must be a pre-blurred, downscaled bitmap (use androidx.renderscript Toolkit or a downsample+box-blur) so the immersive effect still reads
  • Edge-to-edge: call enableEdgeToEdge(); the near-black canvas wants light-content system bars in both nav and status bar. The immersive Now Playing draws behind the status bar (the art glow extends to the top); inset only the top-bar controls
  • Dark-only: never branch on isSystemInDarkTheme() and never enable dynamicColorScheme() — YT Music has no light mode and no wallpaper-harmonized accent; the #FF0000 red and near-black canvas are fixed brand
  • Font scaling: sp honors the user scale on screen title, section, body, row title; pin the Song/Video toggle label, 10sp tab labels, timestamps, and “UP NEXT” eyebrow via dp-derived sizing or a fixed-fontScale density so the player layout doesn’t break
  • Roboto: it is the Android platform default and the YouTube brand face — bundle the explicit weights anyway (Regular/Medium/Bold/Black) so the heavy 900 screen title renders correctly across OEM skins
  • TalkBack: label the play button “Play”/“Pause”; the Song/Video toggle as “Song, selected”/“Video”; expose the scrubber via Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(progress, 0f..1f) } plus custom Seek-forward / Seek-back actions; mark the art-glow backdrop Modifier.clearAndSetSemantics {} (decorative)
  • Touch targets: Material guidance is 48.dp — give the 22–26.dp transport icons and the 12–16.dp scrubber thumb a 48.dp hit area; the 64.dp play button already exceeds it; toggle segments get full-segment 48.dp-tall hit areas
  • Contrast: #FFFFFF and #AAAAAA on #030303 pass WCAG AA; #717171 is for non-essential metadata only. The white play button’s #030303 glyph passes trivially — never recolor it red
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the art-backdrop Crossfade and the toggle slide (substitute an instant swap); keep the scrubber thumb size/color change (it conveys drag state)
  • Video mode: render the 16:9 surface with Media3/ExoPlayer; the Song↔Video toggle swaps the rendering surface while keeping the same Player instance and position — do not recreate the session
All mobile designs →

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