Peloton (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Peloton’s studio-black visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for class browse + in-class metrics (class card, LIVE badge, output ring, metric column, real-time leaderboard), navigation, 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 (pure-black studio canvas, single Peloton-Red accent, cinematic thumbnails, the pulsing LIVE badge, the output ring, the white-chip selected pill, fixed-meaning metric colors) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Canvasfor the rings,LazyColumn+animateItemPlacementfor the live leaderboard,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for class thumbnails and avatars. Peloton is dark-only — there is no light scheme. Do not enable Material You dynamic color: the identity is the fixed pure-black + single Peloton-Red, and metric colors are semantic.
1. Color Tokens
// ui/theme/PLColors.kt
import androidx.compose.ui.graphics.Color
object PLColors {
// Canvas & Surfaces (Dark — the only mode)
val Canvas = Color(0xFF000000) // pure black studio canvas
val Surface1 = Color(0xFF121212)
val Surface2 = Color(0xFF1C1C1E)
val Surface3 = Color(0xFF262629)
val Divider = Color(0xFF2C2C2E)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFB5B5BA)
val TextTertiary = Color(0xFF7A7A80)
// Brand (single accent)
val Red = Color(0xFFDF1E2E) // fills (CTA, LIVE, output ring, your row)
val RedOnDark = Color(0xFFFF4B57) // text / active on black (AA)
val RedPressed = Color(0xFFB81825)
val WhiteChip = Color(0xFFFFFFFF) // selected pill / secondary CTA
// Functional / in-class metrics (fixed meaning — theme-invariant)
val Cadence = Color(0xFFE5402A)
val Resistance = Color(0xFFF0A030)
val Output = Color(0xFFDF1E2E)
val HeartRate = Color(0xFFFF4B57)
val Strive = Color(0xFF3DB8E0)
val PR = Color(0xFF2ECC71)
// Semantic
val Success = Color(0xFF2ECC71)
val Warning = Color(0xFFF0A030)
val Error = Color(0xFFDF1E2E)
val Track = Color(0xFF262629)
}
Wire it into a dark-only scheme — Peloton has no light mode.
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
private val PLDark = darkColorScheme(
primary = PLColors.Red,
onPrimary = PLColors.TextPrimary,
background = PLColors.Canvas,
onBackground = PLColors.TextPrimary,
surface = PLColors.Surface1,
onSurface = PLColors.TextPrimary,
surfaceVariant = PLColors.Surface2,
outline = PLColors.Divider,
error = PLColors.Error,
)
@Composable
fun PLTheme(content: @Composable () -> Unit) = MaterialTheme(
colorScheme = PLDark, // always studio-black; no light scheme
typography = PLTypography,
content = content,
)
2. Typography
Peloton sets Inter heavy and tight. Drop the TTFs (through Black) in res/font/. SIL OFL. Numerals tabular; the metric is the visual hero, labels are small uppercase whispers.
// ui/theme/PLType.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 Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
Font(R.font.inter_extrabold, FontWeight.ExtraBold),
Font(R.font.inter_black, FontWeight.Black),
)
private const val TNUM = "tnum" // tabular figures
object PLText {
val OutputNumber = TextStyle(Inter, fontWeight = FontWeight.Black, fontSize = 44.sp, lineHeight = 44.sp, letterSpacing = (-1.0).sp, fontFeatureSettings = TNUM)
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.6).sp)
val HeroTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.5).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.4).sp)
val ClassTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.3).sp)
val MetricValue = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 18.sp, lineHeight = 20.sp, fontFeatureSettings = TNUM)
val LbOutput = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 14.sp, lineHeight = 14.sp, fontFeatureSettings = TNUM)
val Body = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 22.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val Eyebrow = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 15.sp, letterSpacing = 0.6.sp)
val MetricLabel = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 14.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Badge = TextStyle(Inter, fontWeight = FontWeight.Black, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.6.sp)
}
val PLTypography = Typography(
headlineLarge = PLText.ScreenTitle,
titleLarge = PLText.Section,
bodyLarge = PLText.Body,
labelSmall = PLText.Tab,
)
3. Signature Components
Class Card
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun ClassCard(
discipline: String, title: String, instructor: String,
duration: String, isLive: Boolean, rating: String,
) {
Column(
Modifier
.clip(RoundedCornerShape(14.dp))
.background(PLColors.Surface1)
.border(1.dp, PLColors.Divider, RoundedCornerShape(14.dp)),
) {
Box(
Modifier
.fillMaxWidth()
.height(150.dp)
.background(Brush.linearGradient(listOf(Color(0xFF4A1014), Color(0xFF160508)))),
) {
if (isLive) LiveBadge(Modifier.align(Alignment.TopStart).padding(14.dp))
Text(
duration, style = PLText.Caption, color = PLColors.TextPrimary,
modifier = Modifier
.align(Alignment.TopEnd).padding(14.dp)
.clip(RoundedCornerShape(5.dp))
.background(Color.Black.copy(alpha = 0.6f))
.padding(horizontal = 9.dp, vertical = 5.dp),
)
}
Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp)) {
Text(discipline.uppercase(), style = PLText.Eyebrow, color = PLColors.RedOnDark)
Text(title, style = PLText.ClassTitle, color = PLColors.TextPrimary, modifier = Modifier.padding(top = 4.dp))
Text(instructor, style = PLText.Body, color = PLColors.TextSecondary, modifier = Modifier.padding(top = 3.dp))
Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Star, null, tint = PLColors.Resistance, modifier = Modifier.size(11.dp))
Text(" $rating", style = PLText.Caption, color = PLColors.TextTertiary)
}
}
}
}
LIVE Badge (pulsing dot)
import androidx.compose.animation.core.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
@Composable
fun LiveBadge(modifier: Modifier = Modifier) {
val t = rememberInfiniteTransition(label = "live")
val o by t.animateFloat(
1f, 0.4f,
infiniteRepeatable(tween(700), RepeatMode.Reverse), label = "dot",
)
Row(
modifier
.clip(RoundedCornerShape(5.dp))
.background(PLColors.Red)
.padding(horizontal = 9.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.size(6.dp).clip(CircleShape)
.background(Color.White.copy(alpha = o)))
Text("LIVE", style = PLText.Badge, color = Color.White)
}
}
Output Ring (Canvas)
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun OutputRing(progress: Float, totalKJ: Int) {
val p = remember { Animatable(0f) }
LaunchedEffect(progress) {
p.animateTo(progress, tween(600, easing = LinearOutSlowInEasing))
}
Box(Modifier.size(96.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val sw = 9.dp.toPx()
drawArc(PLColors.Track, 0f, 360f, false, style = Stroke(sw))
drawArc(PLColors.Red, -90f, 360f * p.value, false,
style = Stroke(sw, cap = StrokeCap.Round))
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("$totalKJ", style = PLText.OutputNumber.copy(fontSize = 26.sp),
color = PLColors.TextPrimary)
Text("TOTAL kJ", style = PLText.Badge.copy(fontSize = 9.sp),
color = PLColors.TextTertiary, modifier = Modifier.padding(top = 3.dp))
}
}
}
In-Class Metric Column
data class Metric(val label: String, val value: String, val unit: String = "", val color: Color)
@Composable
fun MetricColumn(metrics: List<Metric>, modifier: Modifier = Modifier) {
Column(modifier.fillMaxWidth()) {
metrics.forEachIndexed { i, m ->
Row(
Modifier.fillMaxWidth().padding(vertical = 7.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
Text(m.label.uppercase(), style = PLText.MetricLabel, color = PLColors.TextSecondary)
Row(verticalAlignment = Alignment.Bottom) {
Text(m.value, style = PLText.MetricValue, color = m.color)
if (m.unit.isNotEmpty())
Text(" ${m.unit}", style = PLText.Caption, color = PLColors.TextTertiary)
}
}
if (i < metrics.lastIndex)
Box(Modifier.fillMaxWidth().height(1.dp).background(PLColors.Divider))
}
}
}
Real-Time Leaderboard
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.text.style.TextAlign
data class Rider(val id: Long, val rank: Int, val name: String, val output: Int, val isMe: Boolean)
@Composable
fun Leaderboard(riders: List<Rider>, total: Int) {
Column(
Modifier
.clip(RoundedCornerShape(14.dp))
.background(PLColors.Surface1)
.border(1.dp, PLColors.Divider, RoundedCornerShape(14.dp)),
) {
Text(
"LEADERBOARD · $total RIDERS", style = PLText.MetricLabel,
color = PLColors.TextSecondary,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 10.dp),
)
LazyColumn(Modifier.heightIn(max = 600.dp)) {
items(riders, key = { it.id }) { r ->
Row(
Modifier
.fillMaxWidth()
.animateItemPlacement(tween(250)) // live reorder
.background(if (r.isMe) PLColors.Red.copy(alpha = 0.12f) else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("${r.rank}", style = PLText.LbOutput,
color = if (r.isMe) PLColors.RedOnDark else PLColors.TextSecondary,
modifier = Modifier.width(26.dp))
Box(Modifier.size(30.dp).clip(CircleShape)
.background(Brush.linearGradient(listOf(Color(0xFF5A2B82), PLColors.Red))))
Text(r.name, style = PLText.Body, color = Color.White, modifier = Modifier.weight(1f))
Text("${r.output}", style = PLText.LbOutput, color = Color.White,
textAlign = TextAlign.End)
}
Box(Modifier.fillMaxWidth().height(1.dp).background(PLColors.Divider))
}
}
}
}
Filter Pill (white-chip) + Primary Button
import androidx.compose.foundation.clickable
@Composable
fun FilterPill(title: String, selected: Boolean, onClick: () -> Unit) {
Box(
Modifier
.clip(RoundedCornerShape(500.dp))
.background(if (selected) PLColors.WhiteChip else PLColors.Surface2)
.then(if (selected) Modifier else Modifier.border(1.dp, PLColors.Divider, RoundedCornerShape(500.dp)))
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Text(title,
style = PLText.Body.copy(fontSize = 13.sp),
color = if (selected) Color.Black else PLColors.TextSecondary)
}
}
@Composable
fun PLButton(
title: String,
variant: PLButtonVariant = PLButtonVariant.Red,
onClick: () -> Unit,
) {
val bg = when (variant) {
PLButtonVariant.Red -> PLColors.Red
PLButtonVariant.White -> Color.White
PLButtonVariant.Outline -> Color.Transparent
}
Box(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(500.dp))
.background(bg)
.then(if (variant == PLButtonVariant.Outline)
Modifier.border(1.5.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(500.dp)) else Modifier)
.clickable(onClick = onClick)
.padding(vertical = 15.dp),
contentAlignment = Alignment.Center,
) {
Text(title, style = PLText.Button,
color = if (variant == PLButtonVariant.White) Color.Black else Color.White)
}
}
enum class PLButtonVariant { Red, White, Outline }
4. Navigation
Peloton has a 4-tab bottom strip with no tint pill — active is just white. The header is a large title or back chevron.
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
@Composable
fun PLBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = PLColors.Surface1, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Classes" to Icons.Filled.PlayCircleFilled,
"Schedule" to Icons.Filled.BarChart,
"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 = PLText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color.White, // active is white
selectedTextColor = Color.White,
unselectedIconColor = PLColors.TextTertiary,
unselectedTextColor = PLColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Peloton has none
),
)
}
}
}
The class-detail screen uses a full-bleed instructor image with a black gradient foot, the discipline eyebrow + 26sp/Black title, and a sticky red PLButton “Take Class” pinned above the navigation-bar inset.
5. Motion
Peloton motion is the LIVE pulse, the ring fill, and the leaderboard reorder; quiet elsewhere.
| Moment | Compose recipe |
|---|---|
| LIVE dot pulse | rememberInfiniteTransition animateFloat 1→0.4 infiniteRepeatable(tween(700), Reverse) |
| Output ring fill | Animatable 0→progress tween(600, LinearOutSlowInEasing) at start; live tween(300) |
| Card tap | Modifier.scale 1→0.98 animateFloatAsState(tween(120)) on press |
| Leaderboard reorder | LazyColumn items keyed by id + Modifier.animateItemPlacement(tween(250)) |
| Pill select | Crossfade/animateColorAsState(tween(150)) on the white fill |
| PR burst | AnimatedVisibility scaleIn(spring(dampingRatio = 0.6f)) + fadeIn, 1.2s, then auto-dismiss |
| Tab switch | Crossfade(tween(150)); glyph color tween 120ms |
| Filter sheet | ModalBottomSheet slide-up (Material default ~300ms) |
// Canonical Peloton motion — output ring filling from zero at class start
val p = remember { Animatable(0f) }
LaunchedEffect(start) { p.animateTo(target, tween(600, easing = LinearOutSlowInEasing)) }
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) (selection analog) on pill select / tab change; HapticFeedbackType.LongPress (medium impact analog) on “Take Class”; a success pattern on PR / class complete. The LIVE pulse is silent (visual only).
6. Icons
Peloton ships custom metric glyphs; the closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house.fill |
Icons.Filled.Home |
| Classes (tab) | play.rectangle.fill |
Icons.Filled.PlayCircleFilled |
| Schedule (tab) | chart.bar.fill |
Icons.Filled.BarChart |
| Profile (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
| Search | magnifyingglass |
Icons.Filled.Search |
| Filter | line.3.horizontal.decrease |
Icons.Filled.Tune |
| Play (thumbnail) | play.fill |
Icons.Filled.PlayArrow |
| Rating star | star.fill |
Icons.Filled.Star |
| Add to Stack | plus.circle |
Icons.Filled.AddCircleOutline |
| Cadence | metronome.fill |
Icons.Filled.Speed |
| Resistance | dial.medium.fill |
Icons.Filled.Tune |
| Output / power | bolt.fill |
Icons.Filled.Bolt |
| Heart rate | heart.fill |
Icons.Filled.Favorite |
| Leaderboard | list.number |
Icons.Filled.FormatListNumbered |
| PR / trophy | trophy.fill |
Icons.Filled.EmojiEvents |
| Music | music.note |
Icons.Filled.MusicNote |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
Canvasrings +animateItemPlacementare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); pure-black canvas wants light-content system bars. The sticky “Take Class” pill respects the navigation-bar inset viaModifier.navigationBarsPadding(); the in-class metric overlay must respect all insets over the video. - Tabular numerals: every metric
TextStylesetsfontFeatureSettings = "tnum"so the output number, metric column, and leaderboard don’t shift as values tick and rows reorder. - Font scaling:
sphonors the user’s font scale — keep it on screen/hero/section titles, body, meta. Pin layout-sensitive text (output number, metric values/labels, leaderboard, 10sp tab labels, badges) viadporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: announce the LIVE badge as “Live class”; metric rows merged as “Cadence 94”; leaderboard rows as “Rank 3, You, 312 output” via
Modifier.semantics(mergeDescendants = true); the outputCanvasas “Total energy 312 kilojoules”. - Color is never the only signal: the LIVE badge carries the word “LIVE”; metrics carry text labels beside the colored value; your leaderboard row is named “You” plus the red wash.
- Touch targets: Material guidance is 48.dp — class card full-card tappable (≥ 200.dp), filter pills ≥ 36.dp with ≥ 48.dp hit, leaderboard rows full-row tappable (≥ 50.dp), buttons ≥ 48.dp.
- Contrast: white on
#000000is maximal;#FF4B57on#000000passes WCAG AA — never render small text in raw#DF1E2Eon black; the white-chip pill is black-on-white (AA+). - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, hold the LIVE dot solid, set the output ring to final value viaCrossfade, drop the card scale, reorder the leaderboard instantly, and show the PR as a static badge. - Dark mode: the only mode — never provide a light scheme; surfaces step
#000000 → #121212 → #1C1C1E; floating layers (filter sheet, dialogs) get#1C1C1E+ a soft0.7black shadow. Do not enable Material YoudynamicColorScheme()— the pure-black + Peloton-Red identity and semantic metric colors must hold regardless of wallpaper.