coderef

ESPN

ESPN's iOS app is a live scoreboard that happens to also be a newsroom. Everything in the design serves one goal: get the score in front of the user in under a second.
DownloadRaw
ESPN

Build with the ESPN look.

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

Palette

At a glance

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

Add it to your project

Then prompt your agent

Design System · ESPN

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

ESPN (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports ESPN’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the scores ticker, the game card, the pulsing LIVE badge, 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 canvas — not pure black, single ESPN Red accent, the pulsing LIVE badge, the green winning score, tabular score numerals) while making everything idiomatic Android — NavigationBar instead of a UITabBar, LazyRow for the ticker, infiniteRepeatable for the pulse, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for team logos and story thumbnails. No color extraction — ESPN’s palette is a fixed dark+red system, so Palette is not needed. ESPN’s scoreboard identity is dark-first; pure black breaks card contrast.

1. Color Tokens

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

object ESPNColors {
    // Canvas & Surfaces (dark-first, near-black — NOT pure black)
    val Canvas   = Color(0xFF0E0F11)
    val Surface1 = Color(0xFF18191B) // game-card body
    val Surface2 = Color(0xFF212327) // ticker chip / card header
    val Surface3 = Color(0xFF2B2D31) // pressed / FINAL fill
    val Divider  = Color(0xFF2A2C30)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFF9AA0A6)
    val TextTertiary  = Color(0xFF6B7075)

    // Brand & status
    val Red        = Color(0xFFD50A0A) // brand accent
    val RedBright  = Color(0xFFCC0000) // CTA / links / category tags
    val RedPressed = Color(0xFFA50808)
    val Live       = Color(0xFFFF1A1A) // LIVE pill + pulse
    val Win        = Color(0xFF1FAA59) // winning team's score
    val RankGold   = Color(0xFFF2C200)
    val Error      = Color(0xFFE5484D)
}

Wire it into a dark-first scheme. ESPN’s scoreboard ground is near-black — never pure black.

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

private val ESPNScheme = darkColorScheme(
    primary        = ESPNColors.Red,            // the single brand accent
    onPrimary      = ESPNColors.TextPrimary,
    background     = ESPNColors.Canvas,
    onBackground   = ESPNColors.TextPrimary,
    surface        = ESPNColors.Surface1,
    onSurface      = ESPNColors.TextPrimary,
    surfaceVariant = ESPNColors.Surface2,
    outline        = ESPNColors.Divider,
    error          = ESPNColors.Error,
)

@Composable
fun ESPNTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = ESPNScheme,   // scoreboard identity is dark — don't branch on isSystemInDarkTheme()
        typography  = ESPNTypography,
        content     = content,
    )

2. Typography

ESPN’s brand face is a proprietary condensed sans; the closest free analog is Archivo (SIL OFL — free to ship). Drop the TTFs in res/font/. Scores/clocks must be tabular — use TextStyle(... , fontFeatureSettings = "tnum").

// ui/theme/ESPNType.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 Archivo = FontFamily(
    Font(R.font.archivo_regular,  FontWeight.Normal),
    Font(R.font.archivo_medium,   FontWeight.Medium),
    Font(R.font.archivo_semibold, FontWeight.SemiBold),
    Font(R.font.archivo_bold,     FontWeight.Bold),
    Font(R.font.archivo_black,    FontWeight.Black),
)

private const val TNUM = "tnum"   // tabular figures — mandatory on scores

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object ESPNText {
    val ScreenTitle   = TextStyle(Archivo, fontWeight = FontWeight.Black,    fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
    val Headline      = TextStyle(Archivo, fontWeight = FontWeight.Black,    fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.3).sp)
    val SectionHeader = TextStyle(Archivo, fontWeight = FontWeight.ExtraBold,fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
    val TeamName      = TextStyle(Archivo, fontWeight = FontWeight.Bold,     fontSize = 18.sp, lineHeight = 22.sp)
    val Body          = TextStyle(Archivo, fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val StoryTitle    = TextStyle(Archivo, fontWeight = FontWeight.Bold,     fontSize = 15.sp, lineHeight = 20.sp)
    val Meta          = TextStyle(Archivo, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 20.sp)
    val Record        = TextStyle(Archivo, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val ButtonLabel   = TextStyle(Archivo, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.3.sp)
    val StatusBadge   = TextStyle(Archivo, fontWeight = FontWeight.Black,    fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.5.sp)
    val CategoryTag   = TextStyle(Archivo, fontWeight = FontWeight.Black,    fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.5.sp)
    val Tab           = TextStyle(Archivo, fontWeight = FontWeight.Bold,     fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    // Scores — tabular, non-negotiable
    val ScoreLarge    = TextStyle(Archivo, fontWeight = FontWeight.Black,    fontSize = 26.sp, lineHeight = 28.sp, fontFeatureSettings = TNUM)
    val ScoreTicker   = TextStyle(Archivo, fontWeight = FontWeight.Bold,     fontSize = 13.sp, lineHeight = 14.sp, fontFeatureSettings = TNUM)
}

// Map onto Material 3 slots so stock components inherit the brand
val ESPNTypography = Typography(
    headlineLarge  = ESPNText.ScreenTitle,
    headlineMedium = ESPNText.Headline,
    titleMedium    = ESPNText.TeamName,
    bodyMedium     = ESPNText.Body,
    labelSmall     = ESPNText.Tab,
)

3. Signature Components

Game Card (the core atom)

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

data class Team(val logo: Color, val name: String, val record: String, val score: Int, val winning: Boolean)

@Composable
fun GameCard(
    league: String,            // "NBA · WESTERN FINALS · GAME 5"
    isLive: Boolean,
    isFinal: Boolean,
    clock: String,             // "Q3 · 4:12"
    home: Team,
    away: Team,
) {
    fun scoreColor(t: Team) =
        if (isFinal) (if (t.winning) ESPNColors.Win else ESPNColors.TextSecondary)
        else (if (t.winning) ESPNColors.TextPrimary else ESPNColors.TextSecondary)

    @Composable
    fun TeamRow(t: Team) {
        Row(
            Modifier.fillMaxWidth().padding(vertical = 8.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(Modifier.size(30.dp).clip(CircleShape).background(t.logo))
            Column(Modifier.weight(1f)) {
                Text(t.name, style = ESPNText.TeamName, color = ESPNColors.TextPrimary)
                Text(t.record, style = ESPNText.Record, color = ESPNColors.TextSecondary)
            }
            Text("${t.score}", style = ESPNText.ScoreLarge, color = scoreColor(t))
        }
    }

    Column(
        Modifier
            .fillMaxWidth()
            .clip(RoundedCornerShape(12.dp))
            .background(ESPNColors.Surface1)
            .border(1.dp, ESPNColors.Divider, RoundedCornerShape(12.dp))
    ) {
        // Header
        Row(
            Modifier.fillMaxWidth().background(ESPNColors.Surface2).padding(horizontal = 14.dp, vertical = 10.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween,
        ) {
            Text(league, style = ESPNText.StatusBadge, color = ESPNColors.TextSecondary)
            StatusBadge(isLive = isLive, isFinal = isFinal, label = if (isLive) "LIVE" else if (isFinal) "FINAL" else clock)
        }
        // Team rows
        Column(Modifier.padding(14.dp)) {
            TeamRow(home)
            HorizontalDivider(color = ESPNColors.Divider, thickness = 1.dp)
            TeamRow(away)
        }
        // Footer
        Row(
            Modifier
                .fillMaxWidth()
                .border(0.dp, Color.Transparent)
                .padding(horizontal = 14.dp, vertical = 10.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween,
        ) {
            Text(clock, style = ESPNText.TeamName.copy(fontSize = 13.sp), color = ESPNColors.TextPrimary)
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = ESPNColors.RedBright, modifier = Modifier.size(12.dp))
                Text("Watch on ESPN+", style = ESPNText.StatusBadge.copy(fontSize = 12.sp), color = ESPNColors.RedBright)
            }
        }
    }
}

Scores Ticker

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items

data class TickerSide(val abbr: String, val logo: Color, val score: String, val win: Boolean)
data class TickerChip(
    val league: String, val status: String, val isLive: Boolean, val isFinal: Boolean,
    val a: TickerSide, val b: TickerSide,
)

@Composable
fun ScoresTicker(chips: List<TickerChip>) {
    @Composable
    fun Line(s: TickerSide) {
        Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween) {
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
                Box(Modifier.size(16.dp).clip(CircleShape).background(s.logo))
                Text(s.abbr, style = ESPNText.ScoreTicker,
                    color = if (s.win) ESPNColors.TextPrimary else ESPNColors.TextSecondary)
            }
            Text(s.score, style = ESPNText.ScoreTicker,
                color = if (s.win) ESPNColors.TextPrimary else ESPNColors.TextSecondary)
        }
    }

    Column {
        LazyRow(
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            contentPadding = PaddingValues(horizontal = 12.dp),
        ) {
            items(chips) { c ->
                Column(
                    Modifier
                        .width(116.dp)
                        .clip(RoundedCornerShape(8.dp))
                        .background(ESPNColors.Surface2)
                        .padding(10.dp),
                    verticalArrangement = Arrangement.spacedBy(6.dp),
                ) {
                    Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.SpaceBetween) {
                        Text(c.league, style = ESPNText.Tab, color = ESPNColors.TextSecondary)
                        StatusBadge(isLive = c.isLive, isFinal = c.isFinal, label = c.status, compact = true)
                    }
                    Line(c.a)
                    Line(c.b)
                }
            }
        }
        Spacer(Modifier.height(12.dp))
        HorizontalDivider(color = ESPNColors.Divider, thickness = 1.dp)
    }
}

Live Status Badge (pulsing dot)

import androidx.compose.animation.core.*
import androidx.compose.runtime.getValue

@Composable
fun StatusBadge(isLive: Boolean, isFinal: Boolean, label: String, compact: Boolean = false) {
    val bg = when { isLive -> ESPNColors.Live; isFinal -> ESPNColors.Surface3; else -> Color.Transparent }
    val fg = if (isFinal) ESPNColors.TextSecondary else ESPNColors.TextPrimary

    val infinite = rememberInfiniteTransition(label = "livePulse")
    val dotAlpha by infinite.animateFloat(
        initialValue = 1f, targetValue = 0.4f,
        animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse),
        label = "dotAlpha",
    )

    Row(
        Modifier
            .clip(RoundedCornerShape(3.dp))
            .background(bg)
            .then(if (!isLive && !isFinal) Modifier.border(1.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(3.dp)) else Modifier)
            .padding(horizontal = if (compact) 5.dp else 9.dp, vertical = if (compact) 2.dp else 4.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(5.dp),
    ) {
        if (isLive) {
            Box(Modifier.size(6.dp).clip(CircleShape).background(Color.White.copy(alpha = dotAlpha)))
        }
        Text(label.uppercase(), style = ESPNText.StatusBadge.copy(fontSize = if (compact) 9.sp else 11.sp), color = fg)
    }
}

Primary CTA (“Watch Live”)

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.remember

@Composable
fun WatchLiveButton(onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Button(
        onClick = onClick,
        interactionSource = interaction,
        shape = RoundedCornerShape(6.dp),
        colors = ButtonDefaults.buttonColors(
            containerColor = if (pressed) ESPNColors.RedPressed else ESPNColors.Red,
            contentColor = ESPNColors.TextPrimary,
        ),
        modifier = Modifier.fillMaxWidth().height(48.dp),
    ) {
        Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
        Spacer(Modifier.width(8.dp))
        Text("Watch Live", style = ESPNText.ButtonLabel)
    }
}

SportsCenter Story Row

import androidx.compose.ui.text.style.TextOverflow

@Composable
fun StoryRow(thumb: Color, category: String, title: String, meta: String) {
    Column {
        Row(
            Modifier.fillMaxWidth().padding(vertical = 14.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(Modifier.size(96.dp, 72.dp).clip(RoundedCornerShape(8.dp)).background(thumb))
            Column(Modifier.weight(1f)) {
                Text(category.uppercase(), style = ESPNText.CategoryTag, color = ESPNColors.RedBright)
                Spacer(Modifier.height(4.dp))
                Text(title, style = ESPNText.StoryTitle, color = ESPNColors.TextPrimary,
                    maxLines = 2, overflow = TextOverflow.Ellipsis)
                Spacer(Modifier.height(6.dp))
                Text(meta, style = ESPNText.Meta.copy(fontSize = 12.sp), color = ESPNColors.TextSecondary)
            }
        }
        HorizontalDivider(color = ESPNColors.Divider, thickness = 1.dp)
    }
}

4. Navigation

ESPN marks the active tab with a small red dot under a white icon — not a Material tint pill. Use a NavigationBar with the indicator transparent and a custom icon that adds the dot.

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

@Composable
fun ESPNBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = ESPNColors.Canvas.copy(alpha = 0.96f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"     to Icons.Filled.Home,
            "Scores"   to Icons.Filled.SportsBasketball,
            "Watch"    to Icons.Filled.PlayCircle,
            "ESPN BET" to Icons.Filled.BarChart,
            "More"     to Icons.Filled.MoreHoriz,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = {
                    Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
                        Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
                        Box(
                            Modifier.size(4.dp).clip(CircleShape)
                                .background(if (selected == i) ESPNColors.Red else Color.Transparent)
                        )
                    }
                },
                label = { Text(label, style = ESPNText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = ESPNColors.TextPrimary,   // white icon when active
                    selectedTextColor   = ESPNColors.TextPrimary,
                    unselectedIconColor = ESPNColors.TextTertiary,
                    unselectedTextColor = ESPNColors.TextTertiary,
                    indicatorColor      = Color.Transparent,         // no Material pill — ESPN uses a red dot
                ),
            )
        }
    }
}

The scores ticker is rendered outside the feed LazyColumn (pinned below the top app bar) so it stays glanceable while the feed scrolls beneath it. The game detail uses a score header + a TabRow (Gamecast / Box Score / Plays / News) with a red Indicator.

5. Motion

ESPN motion is calm except for the two scoreboard cues: the live pulse and the score pop.

Moment Compose recipe
Live pulse rememberInfiniteTransition + animateFloat 1f → 0.4f, infiniteRepeatable(tween(1200), Reverse) on the dot alpha
Score update animateFloatAsState scale 1f → 1.06f → 1f (keyframes/Animatable) + value cross-fade tween(200)
Game ends (LIVE → FINAL) swap badge; animate winner score color via animateColorAsState → Win tween(300)
Tabbed underline TabRow default Indicator recolored Red, slides ~tween(200)
Card press animateFloatAsState scale → 0.98 tween(150)
Tab switch instant content swap; active-dot AnimatedVisibility fadeIn(tween(120))
Breaking alert top banner slideInVertically tween(250) + a brief red pulse, auto-dismiss
// Score pop — the scoreboard "tick"
val pop = remember { Animatable(1f) }
LaunchedEffect(score) {
    pop.animateTo(1.06f, tween(100)); pop.animateTo(1f, tween(100))
}
Text("$score", style = ESPNText.ScoreLarge, modifier = Modifier.graphicsLayer { scaleX = pop.value; scaleY = pop.value })

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for a soft tick on Follow toggle and tab change; a stronger HapticFeedbackConstants.CONFIRM (via LocalView) when a tracked team’s score changes — the “your team scored” buzz.

6. Icons

ESPN iconography is bold and simple; 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
Scores (tab) sportscourt Icons.Filled.SportsBasketball
Watch (tab) play.rectangle Icons.Filled.PlayCircle
ESPN BET (tab) chart.bar Icons.Filled.BarChart
More (tab) ellipsis Icons.Filled.MoreHoriz
Watch / play (CTA) play.fill Icons.Filled.PlayArrow
Follow team plus / checkmark Icons.Filled.Add / Icons.Filled.Check
Alerts / bell bell Icons.Filled.Notifications
Search magnifyingglass Icons.Filled.Search
Share square.and.arrow.up Icons.Filled.Share
Back chevron.left Icons.Filled.ArrowBack
Box score tablecells Icons.Filled.TableChart
Stats chart.xyaxis.line Icons.Filled.ShowChart
Cast airplayvideo Icons.Filled.Cast
Live dot (filled Circle) (a Box circle, not an icon)

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; LazyRow, infiniteRepeatable, fontFeatureSettings are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(). The near-black canvas wants light-content system bars permanently; the top bar + pinned ticker sit below the camera cutout.
  • Dark-first scoreboard: do not branch on isSystemInDarkTheme() for the scoreboard surfaces, and do not enable Material You dynamicColorScheme() — the near-black + ESPN-red identity must hold regardless of wallpaper.
  • Tabular numerals are mandatory: every score / clock / record uses fontFeatureSettings = "tnum" — proportional digits shift columns and break the scoreboard. This is correctness, not style.
  • Font scaling: sp honors the user’s font scale — keep it on headlines, section headers, body, story titles, meta. Pin layout-sensitive text (ticker scores/chips, status badges, 10sp tab labels, the pulse dot, the game-card score size) by deriving from dp so the fixed-rhythm scoreboard doesn’t reflow.
  • TalkBack: label the game card “{away} {awayScore}, {home} {homeScore}, {status}”; the ticker chip the same compactly; the LIVE badge “Live, {period}”; back the win-green and live-red with text (“FINAL”, “LIVE Q3”) so state is not color-only.
  • Color-only meaning: the winning-score green and the live red must be supported by text labels — never convey final/winner or live purely by color.
  • Touch targets: Material guidance is 48.dp. The CTA is 48.dp; give the 22.dp tab icons a 48.dp hit area; game cards are full-card tappable (≥120.dp); ticker chips ≥56.dp; story rows ≥72.dp.
  • Contrast: #FFFFFF on #0E0F11 is maximal; #9AA0A6 meta passes AA at 14sp; the LIVE pill uses white on #FF1A1A (passes AA); win green #1FAA59 on near-black passes AA for the large score.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the live pulse with a static red dot, drop the score pop, and snap the winner color to green (no animation); keep the tabbed underline as a Crossfade.
  • Live updates: drive scores via a websocket/poll worker; on a tracked team’s score change fire the confirm haptic and the 1.06 score pop.

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