coderef

Audible

Audible's iOS app is a literary listening room rendered in software. The canvas is a warm charcoal (#1A1A1A) — not the cold pure-black of music apps, but a deep, paper-at-night gray that frames book covers like a well-lit shelf.
DownloadRaw
Audible

Build with the Audible look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter, Playfair Display, SF Pro Text
Sections
8
Length
3,580 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Audible

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

Audible (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Audible’s visual language to Android with Jetpack Compose (Material 3): a color token object, the serif/sans Typography pairing, the signature speed-dial + 30s-skip player, the cover progress ring, 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 (Audible’s warm charcoal reading room, single-orange accent, serif-headline + sans-body pairing, 30s-skip + speed-dial transport) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a Canvas arc instead of a SwiftUI trimmed Circle, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for images.

1. Color Tokens

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

object AudibleColors {
    // Canvas & Surfaces
    val Canvas   = Color(0xFF1A1A1A)
    val Surface1 = Color(0xFF2A2A2A)
    val Surface2 = Color(0xFF343434)
    val Divider  = Color(0xFF3A3A3A)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFB0B0B0)
    val TextTertiary  = Color(0xFF6E6E6E)

    // Brand
    val Orange        = Color(0xFFFF9900)
    val OrangePressed = Color(0xFFE68A00)
    val OrangeGlow    = Color(0xFFFF9900).copy(alpha = 0.28f)
    val ErrorRed      = Color(0xFFE5484D)
}

Wire it into a Material 3 darkColorScheme. Audible’s player/library are warm-dark by default; do not provide a light scheme for those flows.

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

private val AudibleScheme = darkColorScheme(
    primary        = AudibleColors.Orange,
    onPrimary      = AudibleColors.Canvas,    // dark glyph on orange
    background      = AudibleColors.Canvas,
    onBackground    = AudibleColors.TextPrimary,
    surface         = AudibleColors.Surface1,
    onSurface       = AudibleColors.TextPrimary,
    surfaceVariant  = AudibleColors.Surface2,
    outline         = AudibleColors.Divider,
    error           = AudibleColors.ErrorRed,
)

@Composable
fun AudibleTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = AudibleScheme, typography = AudibleTypography, content = content)

2. Typography

Two families, strict roles. Playfair Display for headings/titles only; Inter for body/UI. Both on Google Fonts — drop the TTFs in res/font/ (lowercase, snake_case). Fall back to serif / system.

// ui/theme/AudibleType.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 PlayfairDisplay = FontFamily(Font(R.font.playfair_display_bold, FontWeight.Bold))   // headings ONLY
val Inter = FontFamily(
    Font(R.font.inter_regular,  FontWeight.Normal),   // 400
    Font(R.font.inter_semibold, FontWeight.SemiBold), // 600
    Font(R.font.inter_bold,     FontWeight.Bold),     // 700
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object AudibleText {
    // Headings — Playfair Display (serif) ONLY
    val TitleLarge = TextStyle(PlayfairDisplay, fontWeight = FontWeight.Bold, fontSize = 30.sp, lineHeight = 35.sp)
    val BookTitle  = TextStyle(PlayfairDisplay, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp)
    val Section    = TextStyle(PlayfairDisplay, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp)
    val CardTitle  = TextStyle(PlayfairDisplay, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 22.sp)
    val MiniTitle  = TextStyle(PlayfairDisplay, fontWeight = FontWeight.Bold, fontSize = 14.sp, lineHeight = 18.sp)

    // Body / UI — Inter (sans)
    val Author     = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 18.sp)
    val Narrator   = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = 0.2.sp)
    val Chapter    = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
    val Body       = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 23.sp)
    val Captions   = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 18.sp, lineHeight = 29.sp)
    val Meta       = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp)
    val LabelUpper = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.8.sp)
    val Button     = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp)
    val ButtonSec  = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 18.sp)
    val Speed      = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 15.sp, lineHeight = 18.sp)
    val Tab        = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val AudibleTypography = Typography(
    headlineLarge = AudibleText.TitleLarge,
    headlineSmall = AudibleText.Section,
    titleMedium   = AudibleText.Chapter,
    bodyMedium    = AudibleText.Body,
    labelSmall    = AudibleText.Tab,
)

3. Signature Components

Cover with Orange Progress Ring

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Composable
fun CoverProgressRing(
    artworkUrl: String,
    progress: Float,
    size: Dp = 280.dp,
    ring: Dp = 4.dp,
    modifier: Modifier = Modifier,
) {
    Box(modifier.size(size), contentAlignment = Alignment.Center) {
        Canvas(Modifier.size(size)) {
            val stroke = ring.toPx()
            val arcSize = Size(this.size.width - stroke, this.size.height - stroke)
            val topLeft = Offset(stroke / 2, stroke / 2)
            drawArc(AudibleColors.Divider, 0f, 360f, false, topLeft, arcSize, style = Stroke(stroke))
            drawArc(
                AudibleColors.Orange, -90f, 360f * progress, false,
                topLeft, arcSize, style = Stroke(stroke, cap = StrokeCap.Round),
            )
        }
        AsyncImage(
            model = artworkUrl,
            contentDescription = null,
            modifier = Modifier
                .size(size - ring * 6)
                .shadow(36.dp, RoundedCornerShape(10.dp), spotColor = Color.Black.copy(alpha = 0.5f))
                .clip(RoundedCornerShape(10.dp)),
            contentScale = ContentScale.Crop,
        )
    }
}

Speed-Dial + 30s-Skip Transport (Signature)

@Composable
fun AudiblePlayButton(isPlaying: Boolean, onClick: () -> Unit, size: Dp = 72.dp) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.93f else 1f, spring(dampingRatio = 0.72f), label = "play")
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier
            .size(size).scale(scale)
            .shadow(22.dp, CircleShape, spotColor = AudibleColors.Orange.copy(alpha = 0.28f))
            .clip(CircleShape)
            .background(if (pressed) AudibleColors.OrangePressed else AudibleColors.Orange)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Icon(
            if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
            contentDescription = if (isPlaying) "Pause" else "Play",
            tint = AudibleColors.Canvas, // dark glyph on orange
            modifier = Modifier.size(size * 0.44f),
        )
    }
}

@Composable
fun SkipButton(forward: Boolean, onClick: () -> Unit) {
    val haptics = LocalHapticFeedback.current
    var flash by remember { mutableStateOf(false) }
    val tint by animateColorAsState(if (flash) AudibleColors.Orange else Color.White, tween(180), label = "flash")
    val scope = rememberCoroutineScope()
    Box(
        Modifier
            .size(44.dp)
            .clickable(remember { MutableInteractionSource() }, indication = null) {
                flash = true
                scope.launch { kotlinx.coroutines.delay(200); flash = false }
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        // Replace30 / Forward30 carry the literal "30" — Audible's signature transport
        Icon(
            if (forward) Icons.Filled.Forward30 else Icons.Filled.Replay30,
            contentDescription = if (forward) "Skip forward 30 seconds" else "Skip back 30 seconds",
            tint = tint,
            modifier = Modifier.size(30.dp),
        )
    }
}

Speed Dial Sheet

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SpeedDialSheet(speed: Float, onSpeed: (Float) -> Unit, onDismiss: () -> Unit) {
    val sheet = rememberModalBottomSheetState()
    val haptics = LocalHapticFeedback.current
    var lastTick by remember { mutableStateOf(speed) }
    val presets = listOf(1.0f, 1.25f, 1.5f, 2.0f)

    ModalBottomSheet(
        onDismissRequest = onDismiss,
        sheetState = sheet,
        containerColor = AudibleColors.Surface1,
        shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
    ) {
        Column(
            Modifier.padding(20.dp).fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(24.dp),
        ) {
            Text(
                "%.2f×".format(speed).trimEnd('0').trimEnd('.') + "×".takeIf { false }.orEmpty(),
                style = AudibleText.Speed.copy(fontSize = 22.sp),
                color = AudibleColors.Orange,
            )
            Slider(
                value = speed, onValueChange = {
                    if (Math.round(it * 4) != Math.round(lastTick * 4)) {
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                        lastTick = it
                    }
                    onSpeed(it)
                },
                valueRange = 0.5f..3.5f, steps = ((3.5f - 0.5f) / 0.05f).toInt() - 1,
                colors = SliderDefaults.colors(
                    thumbColor = AudibleColors.Orange,
                    activeTrackColor = AudibleColors.Orange,
                    inactiveTrackColor = AudibleColors.Divider,
                ),
            )
            Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                presets.forEach { p ->
                    val sel = speed == p
                    Box(
                        Modifier
                            .clip(RoundedCornerShape(8.dp))
                            .background(if (sel) AudibleColors.Orange else AudibleColors.Surface2)
                            .clickable { onSpeed(p) }
                            .padding(horizontal = 16.dp, vertical = 8.dp),
                    ) {
                        Text("${p}×", style = AudibleText.Speed, color = if (sel) AudibleColors.Canvas else Color.White)
                    }
                }
            }
            Spacer(Modifier.height(8.dp))
        }
    }
}

Continue-Listening Row

@Composable
fun ContinueRow(title: String, author: String, remaining: String, artworkUrl: String, progress: Float) {
    Row(
        Modifier
            .clip(RoundedCornerShape(12.dp))
            .background(AudibleColors.Surface1)
            .padding(14.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        CoverProgressRing(artworkUrl, progress, size = 72.dp, ring = 3.dp)
        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
            Text(title, style = AudibleText.CardTitle, color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis)
            Text(author, style = AudibleText.Author.copy(fontSize = 13.sp), color = AudibleColors.TextSecondary, maxLines = 1)
            Text(remaining, style = AudibleText.Meta.copy(fontSize = 12.sp), color = AudibleColors.TextSecondary)
        }
        Box(
            Modifier.size(56.dp).clip(CircleShape).background(AudibleColors.Orange),
            contentAlignment = Alignment.Center,
        ) { Icon(Icons.Filled.PlayArrow, "Play", tint = AudibleColors.Canvas, modifier = Modifier.size(22.dp)) }
    }
}

Chapter List Sheet

enum class ChapterState { Playing, Finished, Upcoming }

@Composable
fun ChapterRow(n: Int, title: String, dur: String, state: ChapterState) {
    Row(
        Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Box(Modifier.width(3.dp).height(28.dp).background(if (state == ChapterState.Playing) AudibleColors.Orange else Color.Transparent))
        Text("$n.", style = AudibleText.Chapter, color = AudibleColors.TextSecondary)
        Text(
            title,
            style = AudibleText.Chapter,
            color = if (state == ChapterState.Playing) AudibleColors.Orange else Color.White,
            modifier = Modifier.weight(1f),
        )
        if (state == ChapterState.Finished) {
            Icon(Icons.Filled.Check, "Finished", tint = AudibleColors.Orange, modifier = Modifier.size(14.dp))
        }
        Text(dur, style = AudibleText.Meta, color = AudibleColors.TextSecondary)
    }
}

4. Captions (Synced Text)

@Composable
fun Captions(line: String, activeWord: Int) {
    FlowRow(
        Modifier
            .clip(RoundedCornerShape(12.dp))
            .background(AudibleColors.Surface1)
            .padding(16.dp),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        line.split(" ").forEachIndexed { i, word ->
            val c by animateColorAsState(
                if (i == activeWord) Color.White else AudibleColors.TextSecondary, tween(180), label = "w$i",
            )
            Text(word, style = AudibleText.Captions, color = c)
        }
    }
}

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. Audible’s iOS tab bar is .regularMaterial blur over warm charcoal; Android has no first-class live blur, so use a 96%-opaque charcoal surface. Active tint is Audible Orange.

@Composable
fun AudibleBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = AudibleColors.Canvas.copy(alpha = 0.96f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"     to Icons.Filled.Home,
            "Library"  to Icons.Filled.LibraryBooks,
            "Discover" to Icons.Filled.Search,
            "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(24.dp)) },
                label = { Text(label, style = AudibleText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = AudibleColors.Orange,
                    selectedTextColor   = AudibleColors.Orange,
                    unselectedIconColor = AudibleColors.TextSecondary,
                    unselectedTextColor = AudibleColors.TextSecondary,
                    indicatorColor      = Color.Transparent, // Audible has no Material pill
                ),
            )
        }
    }
}

The persistent Now Playing mini-bar sits directly above this bar: a 56.dp Surface(color = AudibleColors.Surface1) row with a 3.dp AudibleColors.Orange progress line pinned to its top edge, a Playfair-Display title + Inter author — render it in the Scaffold bottomBar slot stacked above AudibleBottomBar.

6. Motion

Moment Compose recipe
Play tap animateFloatAsState 1 → 0.93 spring(dampingRatio = 0.72f), HapticFeedbackType.LongPress
30s skip animateColorAsState white → orange over tween(180); HapticFeedbackType.TextHandleMove; ring jumps 30s
Speed dial each .25 tick → HapticFeedbackType.TextHandleMove; big number AnimatedContent cross-fade
Cover progress ring animate the arc sweep linearly from playback position (no easing)
Chapter change animate the orange leading bar offset with tween(220)
Mini-bar → full player SharedTransitionLayout + Modifier.sharedElement() on the cover (Compose 1.7+); fallback AnimatedVisibility + slideInVertically
Captions per-word animateColorAsState over tween(180)
// Live cover-ring sweep from the player position
val progress by produceState(0f) {
    player.positionFlow.collect { value = (it.position.toFloat() / it.duration).coerceIn(0f, 1f) }
}

Haptics: prefer LocalHapticFeedback. For speed-dial detents and skip, LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) (API 30+) approximates iOS selection feedback.

7. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. Audible’s 30-second skip maps to Icons.Filled.Replay30 / Icons.Filled.Forward30 (they carry the literal “30”).

Purpose SF Symbol (iOS) Material Icon (Compose)
Play play.fill Icons.Filled.PlayArrow
Pause pause.fill Icons.Filled.Pause
Skip back 30s gobackward.30 Icons.Filled.Replay30
Skip forward 30s goforward.30 Icons.Filled.Forward30
Sleep timer moon.zzz Icons.Filled.Bedtime
Bookmark bookmark / bookmark.fill Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark
Chapters list.bullet Icons.Filled.FormatListBulleted
Car mode car Icons.Filled.DirectionsCar
Captions captions.bubble Icons.Filled.ClosedCaption
Finished check checkmark Icons.Filled.Check
Share square.and.arrow.up Icons.Filled.Share
More ellipsis Icons.Filled.MoreHoriz
Search magnifyingglass Icons.Filled.Search
Home (tab) house.fill Icons.Filled.Home
Library (tab) books.vertical.fill Icons.Filled.LibraryBooks

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; the Canvas ring + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the warm charcoal wants light-content system bars. Apply Scaffold insets so the mini-bar clears gesture nav.
  • Font scaling: sp honors the user’s font scale — keep it on titles, authors, body, chapters, and captions especially (readability while listening). Pin the speed value and tab labels by deriving size from dp.
  • Serif discipline: never apply PlayfairDisplay to body, metadata, or captions — its high stroke contrast thins at small sizes; keep it at 17sp+.
  • TalkBack: label skip buttons “Skip back/forward 30 seconds”; expose speed via Modifier.semantics { progressBarRangeInfo = ProgressBarRangeInfo(speed, 0.5f..3.5f) } and a set action; mirror the ring with a contentDescription (“34 percent, 8 hours 14 minutes remaining”).
  • Touch targets: the 72.dp play button clears the 48.dp minimum; give the 30.dp skip glyphs a 48.dp hit area via the 44.dp box + padding.
  • Contrast: #B0B0B0 on #1A1A1A passes WCAG AA at 14sp+; validate the 12-13sp meta and lighten toward #BDBDBD if targeting strict compliance.
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — Audible’s brand requires the fixed warm charcoal canvas and single-orange accent regardless of wallpaper.
All mobile designs →

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