coderef

Goodreads

Goodreads' iOS app feels like a well-worn personal library rendered on a screen. The defining gesture of the entire product is a wall of book covers — full-color, photographic jacket art arranged in tight grids and horizontal shelves …
DownloadRaw
Goodreads

Build with the Goodreads look.

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

Palette

At a glance

Theme
light
Typefaces
Lato, Merriweather
Sections
7
Length
4,092 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Goodreads

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

Goodreads (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Goodreads’ visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the book detail + star rating, 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 (Goodreads’ warm tan paper, literary brown chrome, amber five-star rating, cover-first shelves) while making everything idiomatic Android — NavigationBar instead of a UITabBar, ModalBottomSheet instead of a SwiftUI sheet, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for cover art and avatars. No color extraction — Goodreads’ palette is a fixed warm set, so Palette is not needed. Goodreads is light-mode-first (paper); a full dark scheme is provided.

1. Color Tokens

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

object GoodreadsColors {
    // Brand & interactive
    val Brown        = Color(0xFF382110)
    val BrownDeep    = Color(0xFF58371F)
    val Tan          = Color(0xFFF4F1EA)
    val ShelfGreen   = Color(0xFF409D69)
    val ShelfGreenPressed = Color(0xFF348355)
    val LinkTeal     = Color(0xFF00635D)
    val LinkTealDark = Color(0xFF6FB3AD)

    // Rating
    val Amber        = Color(0xFFE9A100) // load-bearing — five-star rating, light AND dark
    val StarRail     = Color(0xFFD8D2C4)
    val StarRailDark = Color(0xFF4A3F2C)

    // Canvas & surfaces (light)
    val Canvas        = Color(0xFFF4F1EA) // warm tan paper — NOT pure white
    val CardWhite     = Color(0xFFFFFFFF)
    val SurfaceSubtle = Color(0xFFEBE6DA)
    val Divider       = Color(0xFFDDD6C7)

    // Canvas & surfaces (dark)
    val DarkCanvas   = Color(0xFF161310) // warm ink-brown — NOT true black
    val DarkSurface1 = Color(0xFF211C16)
    val DarkSurface2 = Color(0xFF2C261D)
    val DarkDivider  = Color(0xFF3A3226)
    val BrownOnDark  = Color(0xFFC9883D) // brown reads muddy on dark → tan-gold accent

    // Text
    val TextPrimary     = Color(0xFF382110) // warm brown-black — NOT pure black
    val TextSecondary   = Color(0xFF6B5E47)
    val TextTertiary    = Color(0xFF988B6F)
    val DarkTextPrimary = Color(0xFFEDE6D8)
    val DarkTextSecondary = Color(0xFFB3A88F)

    // Semantic
    val Error = Color(0xFFD9534F)
}

Wire it into both schemes. Goodreads is light-first (paper); the dark scheme uses the signature #161310 ink-brown, never true black.

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

private val GoodreadsLight = lightColorScheme(
    primary        = GoodreadsColors.Brown,
    onPrimary      = GoodreadsColors.Tan,
    secondary      = GoodreadsColors.ShelfGreen,
    onSecondary    = Color(0xFFFFFFFF),
    background      = GoodreadsColors.Canvas,
    onBackground    = GoodreadsColors.TextPrimary,
    surface        = GoodreadsColors.CardWhite,
    onSurface      = GoodreadsColors.TextPrimary,
    surfaceVariant = GoodreadsColors.SurfaceSubtle,
    outline        = GoodreadsColors.Divider,
    error          = GoodreadsColors.Error,
)

private val GoodreadsDark = darkColorScheme(
    primary        = GoodreadsColors.BrownOnDark,
    onPrimary      = GoodreadsColors.DarkCanvas,
    secondary      = GoodreadsColors.ShelfGreen,
    onSecondary    = Color(0xFFFFFFFF),
    background      = GoodreadsColors.DarkCanvas,
    onBackground    = GoodreadsColors.DarkTextPrimary,
    surface        = GoodreadsColors.DarkSurface1,
    onSurface      = GoodreadsColors.DarkTextPrimary,
    surfaceVariant = GoodreadsColors.DarkSurface2,
    outline        = GoodreadsColors.DarkDivider,
    error          = GoodreadsColors.Error,
)

@Composable
fun GoodreadsTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) GoodreadsDark else GoodreadsLight,
    typography = GoodreadsTypography,
    content = content,
)

2. Typography (Material 3)

Goodreads pairs Merriweather (editorial: titles, synopses, reviews) with Lato (UI chrome). Drop the TTFs in res/font/. Serif for what the user reads; sans for what the user acts on. Body/review line-height ≈ 1.6.

// ui/theme/GoodreadsType.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 Merriweather = FontFamily(
    Font(R.font.merriweather_regular, FontWeight.Normal),
    Font(R.font.merriweather_bold,    FontWeight.Bold),
    Font(R.font.merriweather_black,   FontWeight.Black),
)
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 GoodreadsText {
    val ScreenTitle = TextStyle(Merriweather, fontWeight = FontWeight.Black,  fontSize = 30.sp, lineHeight = 36.sp, letterSpacing = (-0.3).sp)
    val BookTitle   = TextStyle(Merriweather, fontWeight = FontWeight.Black,  fontSize = 23.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val Section     = TextStyle(Merriweather, fontWeight = FontWeight.Bold,   fontSize = 19.sp, lineHeight = 25.sp)
    val Subsection  = TextStyle(Merriweather, fontWeight = FontWeight.Bold,   fontSize = 17.sp, lineHeight = 23.sp)
    val Body        = TextStyle(Merriweather, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 24.sp) // ~1.6
    val Review      = TextStyle(Merriweather, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 20.sp)
    val Button      = TextStyle(Lato, fontWeight = FontWeight.Black,  fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = 0.1.sp)
    val Label       = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 14.sp, lineHeight = 18.sp)
    val Meta        = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
    val Link        = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 13.sp, lineHeight = 18.sp)
    val Caption     = TextStyle(Lato, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
    val StarCaption = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 12.sp, lineHeight = 14.sp)
    val Overline    = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
    val Tab         = TextStyle(Lato, fontWeight = FontWeight.Bold,   fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
}

// Map onto Material 3 slots so stock components inherit the brand voice
val GoodreadsTypography = Typography(
    headlineLarge  = GoodreadsText.ScreenTitle,
    headlineMedium = GoodreadsText.BookTitle,
    titleMedium    = GoodreadsText.Section,
    bodyMedium     = GoodreadsText.Body,
    labelSmall     = GoodreadsText.Tab,
)

3. Signature Components

Book Cover

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun BookCover(imageUrl: String?, width: Int = 104, dark: Boolean = false) {
    val w = width.dp
    val h = (width * 1.52f).dp
    Box(
        Modifier
            .size(w, h)
            .shadow(if (dark) 8.dp else 5.dp, RoundedCornerShape(3.dp),
                    spotColor = Color.Black.copy(alpha = if (dark) 0.55f else 0.18f))
            .clip(RoundedCornerShape(3.dp))
    ) {
        AsyncImage(
            model = imageUrl,
            contentDescription = null,
            modifier = Modifier.matchParentSize()
                .background(Brush.linearGradient(listOf(GoodreadsColors.BrownDeep, GoodreadsColors.Brown))),
            contentScale = ContentScale.Crop,
        )
        Box(Modifier.fillMaxHeight().width(6.dp).background(Color.Black.copy(alpha = 0.35f)))
    }
}

Star Rating (display, fractional)

import androidx.compose.foundation.Canvas
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.clipToBounds

@Composable
fun StarRating(value: Float, size: Int = 15, dark: Boolean = false) {
    val rail = if (dark) GoodreadsColors.StarRailDark else GoodreadsColors.StarRail
    Row(horizontalArrangement = Arrangement.spacedBy(1.dp)) {
        repeat(5) { i ->
            val fill = (value - i).coerceIn(0f, 1f)
            Box(Modifier.size(size.dp)) {
                Icon(Icons.Filled.Star, null, tint = rail, modifier = Modifier.matchParentSize())
                Box(Modifier.fillMaxHeight().fillMaxWidth(fill).clipToBounds()) {
                    Icon(Icons.Filled.Star, null, tint = GoodreadsColors.Amber,
                         modifier = Modifier.size(size.dp))
                }
            }
        }
    }
}

Interactive “Rate this Book”

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextAlign

@Composable
fun RateThisBook(dark: Boolean = false, onRate: (Int) -> Unit = {}) {
    var rating by remember { mutableIntStateOf(0) }
    val haptics = LocalHapticFeedback.current
    Column(
        Modifier
            .padding(horizontal = 16.dp)
            .clip(RoundedCornerShape(6.dp))
            .background(if (dark) GoodreadsColors.DarkSurface1 else GoodreadsColors.CardWhite)
            .padding(14.dp)
            .fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text("RATE THIS BOOK", style = GoodreadsText.Overline,
             color = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextSecondary,
             textAlign = TextAlign.Center)
        Spacer(Modifier.height(10.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
            (1..5).forEach { i ->
                val s by animateFloatAsState(if (i == rating) 1.2f else 1f,
                    spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMedium), label = "starPop")
                Icon(
                    Icons.Filled.Star, null,
                    tint = if (i <= rating) GoodreadsColors.Amber
                           else if (dark) GoodreadsColors.StarRailDark else GoodreadsColors.StarRail,
                    modifier = Modifier.size(28.dp).scale(if (i == rating) s else 1f).clickable {
                        rating = i
                        haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                        onRate(i)
                    },
                )
            }
        }
    }
}

Want-to-Read CTA Block

import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults

@Composable
fun ShelfCTA() {
    var shelf by remember { mutableStateOf<String?>(null) }
    val haptics = LocalHapticFeedback.current
    Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Button(
            onClick = { shelf = shelf ?: "Want to Read"; haptics.performHapticFeedback(HapticFeedbackType.LongPress) },
            shape = RoundedCornerShape(4.dp),
            colors = ButtonDefaults.buttonColors(containerColor = GoodreadsColors.ShelfGreen),
            modifier = Modifier.fillMaxWidth().height(44.dp),
        ) {
            if (shelf != null) { Icon(Icons.Filled.Check, null, tint = Color.White, modifier = Modifier.size(16.dp)); Spacer(Modifier.width(8.dp)) }
            Text(shelf ?: "Want to Read", style = GoodreadsText.Button, color = Color.White)
        }
        Row(
            Modifier.fillMaxWidth().border(1.dp, GoodreadsColors.Divider, RoundedCornerShape(4.dp))
                .clip(RoundedCornerShape(4.dp)),
        ) {
            listOf("★ Rate", "Review", "Shelve ▾").forEachIndexed { i, seg ->
                Box(Modifier.weight(1f).height(38.dp), contentAlignment = Alignment.Center) {
                    Text(seg, style = GoodreadsText.StarCaption,
                         color = if (seg == "★ Rate") GoodreadsColors.Amber else GoodreadsColors.TextSecondary)
                }
                if (i < 2) Box(Modifier.fillMaxHeight().width(1.dp).background(GoodreadsColors.Divider))
            }
        }
    }
}

Shelf Status Pill

@Composable
fun ShelfPill(kind: String) { // "read" | "current" | "want"
    data class P(val bg: Color, val fg: Color, val border: Color?)
    val p = when (kind) {
        "read"    -> P(GoodreadsColors.Brown, GoodreadsColors.Tan, null)
        "current" -> P(GoodreadsColors.ShelfGreen.copy(alpha = 0.18f), GoodreadsColors.ShelfGreen, GoodreadsColors.ShelfGreen.copy(alpha = 0.4f))
        else      -> P(GoodreadsColors.SurfaceSubtle, GoodreadsColors.TextSecondary, GoodreadsColors.Divider)
    }
    val label = when (kind) { "read" -> "Read"; "current" -> "Currently Reading"; else -> "Want to Read" }
    Box(
        Modifier
            .clip(RoundedCornerShape(999.dp))
            .background(p.bg)
            .then(if (p.border != null) Modifier.border(1.dp, p.border, RoundedCornerShape(999.dp)) else Modifier)
            .padding(horizontal = 14.dp, vertical = 7.dp),
    ) { Text(label, style = GoodreadsText.Link, color = p.fg) }
}

4. Navigation

Goodreads has a 5-tab bottom strip and a serif large title that collapses on scroll. On Android, model the strip as a NavigationBar (no tint pill — active is glyph fill + color) and the detail screen with a LargeTopAppBar.

import androidx.compose.material.icons.filled.*
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults

@Composable
fun GoodreadsBottomBar(selected: Int, onSelect: (Int) -> Unit, dark: Boolean = false) {
    NavigationBar(
        containerColor = if (dark) GoodreadsColors.DarkCanvas else GoodreadsColors.Canvas,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home" to Icons.Filled.Home,
            "My Books" to Icons.Filled.MenuBook,
            "Browse" to Icons.Filled.Search,
            "Updates" to Icons.Filled.Notifications,
            "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(23.dp)) },
                label = { Text(label, style = GoodreadsText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = if (dark) GoodreadsColors.BrownOnDark else GoodreadsColors.Brown,
                    selectedTextColor = if (dark) GoodreadsColors.BrownOnDark else GoodreadsColors.Brown,
                    unselectedIconColor = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextTertiary,
                    unselectedTextColor = if (dark) GoodreadsColors.DarkTextSecondary else GoodreadsColors.TextTertiary,
                    indicatorColor = Color.Transparent, // no Material pill — Goodreads has none
                ),
            )
        }
    }
}

The shelf picker is a ModalBottomSheet (12.dp top radius): exclusive shelves as a radio group (Read / Currently Reading / Want to Read), custom shelves as a checkbox list below a Divider, a green “Done” Button pinned at the bottom.

5. Motion

Goodreads motion is gentle and library-quiet — 180–300ms.

Moment Compose recipe
Shelve action green Button animateFloatAsState scale 1→0.96→1 spring(0.6); success haptic
Star commit per-star animateFloatAsState 1→1.2→1 spring(dampingRatio=0.55f), staggered fill; HapticFeedbackType.LongPress
“…more” expander AnimatedVisibility(expandVertically(tween(220)) + fadeIn())
Cover → detail shared-element via Modifier.sharedElement (Compose 1.7) or scale tween(300)
Reading Challenge ring Canvas arc sweep animateFloatAsState(target, tween(700)) on first composition
Pull to refresh PullToRefreshContainer with brown indicator
Tab switch instant; selected glyph crossfades tween(120)
// Star bounce — the canonical Goodreads rating motion
val pop by animateFloatAsState(
    targetValue = if (i == rating) 1.2f else 1f,
    animationSpec = spring(dampingRatio = 0.55f, stiffness = Spring.StiffnessMedium),
    label = "starPop",
)

Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the medium impact on star commit and the soft confirm on shelve. Progress auto-save is silent — show a “Saved” Snackbar only on a manual progress update or an error.

6. Icons

Goodreads’ iconography is conventional; the closest first-party set is androidx.compose.material:material-icons-extended. The five-star rating uses Icons.Filled.Star (clip a copy for fractional fill); avatars fall back to initials on a brown gradient.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house / house.fill Icons.Filled.Home
My Books (tab) books.vertical Icons.Filled.MenuBook
Browse (tab) magnifyingglass Icons.Filled.Search
Updates (tab) bell / bell.fill Icons.Filled.Notifications
Profile (tab) person.crop.circle Icons.Filled.AccountCircle
Star (rating) star.fill Icons.Filled.Star
Half star star.leadinghalf.filled Icons.AutoMirrored.Filled.StarHalf
Back chevron.left Icons.AutoMirrored.Filled.ArrowBack
Share square.and.arrow.up Icons.Filled.IosShare
Add to network person.2 Icons.Filled.GroupAdd
Shelve chevron chevron.down Icons.Filled.ExpandMore
Want to Read check checkmark Icons.Filled.Check
Progress update arrow.triangle.2.circlepath Icons.Filled.Sync
Like (review) heart / heart.fill Icons.Filled.FavoriteBorder / Icons.Filled.Favorite
Comment text.bubble Icons.AutoMirrored.Filled.Comment
Scan ISBN barcode.viewfinder Icons.Filled.QrCodeScanner

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the tan light canvas wants dark-content system bars (light-content in dark mode). The reviews feed and cover grids are long — use LazyVerticalGrid / LazyColumn.
  • Font scaling: sp honors the user’s font scale — keep it on screen titles, book titles, body, review prose (reading content must scale). Pin layout-sensitive text (10sp tab labels, overlines, star captions, shelf pills) by deriving from dp or CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)).
  • Reading content: never collapse the 1.6 line-height on Body/Review — long reviews must stay comfortable at large font scales.
  • TalkBack: announce ratings as “Rated 4 of 5 stars, 3,284,991 ratings”; the interactive rate row uses Modifier.semantics { stateDescription = "$rating of 5 stars"; role = Role.... } and a custom set action; give every BookCover a contentDescription of “{title} by {author}”.
  • Touch targets: Material guidance is 48.dp. Give the 28.dp interactive stars a 48.dp hit area via padding; sidebar/list rows are full-row tappable; primary CTA ≥ 44.dp.
  • Contrast: #382110 on #F4F1EA passes WCAG AA. Amber #E9A100 is a graphical rating; always pair it with the numeric average so meaning never rests on color alone — never grey-fill a fractional star (clip the amber instead).
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the star pop and the ring sweep — substitute a plain Crossfade; keep the fill state (it conveys the rating).
  • Dark mode: invert via the Dark* palette — #161310, NOT true black; #382110 text becomes #EDE6D8; brand brown becomes tan-gold #C9883D for active tabs; link teal lightens to #6FB3AD. Cover shadows deepen to 0.55 alpha so jackets still detach. Do not enable Material You dynamicColorScheme() — Goodreads’ paper-and-ink identity and the fixed amber rating must hold 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