Headspace (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Headspace’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the breathing sphere, the Aurora wash, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Headspace’s butter-cream canvas, Marigold accent, the breathing meditation sphere, illustration-companion warmth) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, a translucentSurfaceinstead of iOS blur,sp/dpinstead ofpt, and anAnimatable-driven 4-2-4-2 breath cycle instead of SwiftUI’swithAnimation.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for the illustration assets (PNG @2x/@3x equivalents bundled as drawables or loaded remotely). Headspace does no runtime color extraction, so no Palette dependency is needed.
1. Color Tokens
// ui/theme/HeadspaceColors.kt
import androidx.compose.ui.graphics.Color
object HeadspaceColors {
// Canvas & Surfaces (light / default mode)
val ButterCream = Color(0xFFFFF7E7)
val OffCream = Color(0xFFFBF1DA)
val WarmGray = Color(0xFFE8DFC9)
val SurfaceWhite = Color(0xFFFFFFFF)
// Brand — Marigold
val Marigold = Color(0xFFFF6B35)
val MarigoldPressed = Color(0xFFE55A2B)
val MarigoldLight = Color(0xFFFFB89E)
// Aurora gradient stops (signature dawn wash)
val Aurora1 = Color(0xFFFFB89E) // peachy dawn (top)
val Aurora2 = Color(0xFFFFD25C) // butter sun (mid)
val Aurora3 = Color(0xFFFF6B35) // marigold morning (bottom)
// Secondary accents
val Butter = Color(0xFFFFD25C)
val SageMoss = Color(0xFF7E9F4B)
val SageLight = Color(0xFFB4C68F)
val CoralFlush = Color(0xFFF4877E)
val SkyLavender = Color(0xFFB7B0DC)
// Text — Ink Brown (warm purple-brown, never harsh black)
val InkBrown = Color(0xFF2E1A47)
val InkBrownSoft = Color(0xFF594675)
val InkBrownMute = Color(0xFF8E7DA5)
// Dusk (Sleep mode)
val DuskCanvas = Color(0xFF1A1430)
val DuskSurface1 = Color(0xFF2A2046)
val DuskSurface2 = Color(0xFF3A2F5C)
val DuskDivider = Color(0xFF3F3460)
// Semantic
val Success = Color(0xFF7E9F4B) // same as Sage
val Warning = Color(0xFFFF9F4A)
val ErrorCoral = Color(0xFFE04646)
// Milestone
val MilestoneGold = Color(0xFFE5B85C)
}
Headspace’s default mode is the warm butter-cream light surface, so wire a Material 3 lightColorScheme so ripples, dividers, and default component colors inherit the brand. Provide a separate dusk scheme for Sleep mode rather than relying on Android’s isSystemInDarkTheme() — the dusk world is a deliberate in-app mode, not a system preference.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val HeadspaceLight = lightColorScheme(
primary = HeadspaceColors.Marigold,
onPrimary = HeadspaceColors.SurfaceWhite,
background = HeadspaceColors.ButterCream,
onBackground = HeadspaceColors.InkBrown,
surface = HeadspaceColors.OffCream,
onSurface = HeadspaceColors.InkBrown,
surfaceVariant = HeadspaceColors.WarmGray,
outline = HeadspaceColors.WarmGray,
error = HeadspaceColors.ErrorCoral,
)
private val HeadspaceDusk = darkColorScheme(
primary = HeadspaceColors.SageMoss, // Sage is the Sleep accent, not Marigold
onPrimary = HeadspaceColors.ButterCream,
background = HeadspaceColors.DuskCanvas,
onBackground = HeadspaceColors.ButterCream,
surface = HeadspaceColors.DuskSurface1,
onSurface = HeadspaceColors.ButterCream,
surfaceVariant = HeadspaceColors.DuskSurface2,
outline = HeadspaceColors.DuskDivider,
)
@Composable
fun HeadspaceTheme(sleepMode: Boolean = false, content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (sleepMode) HeadspaceDusk else HeadspaceLight,
typography = HeadspaceTypography,
content = content,
)
2. Typography
Apercu is licensed via Colophon Foundry. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to Nunito (the closest soft humanist sans on Google Fonts, sharing Apercu’s curled ‘a’); the system default Roboto is a colder last resort.
// ui/theme/HeadspaceType.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 Apercu = FontFamily(
Font(R.font.apercu_light, FontWeight.Light), // 300
Font(R.font.apercu_regular, FontWeight.Normal), // 400
Font(R.font.apercu_medium, FontWeight.Medium), // 500
Font(R.font.apercu_bold, FontWeight.Bold), // 700
Font(R.font.apercu_black, FontWeight.Black), // 900
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, title case only)
object HeadspaceText {
val GreetingHero = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.3).sp)
val ScreenTitle = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.3).sp)
val MedTitle = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
val SectionHdr = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val CardTitle = TextStyle(Apercu, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Apercu, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp) // generous 1.5
val BodyBold = TextStyle(Apercu, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp)
val Guidance = TextStyle(Apercu, fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 31.sp) // play-screen breath text
val Meta = TextStyle(Apercu, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val TagPill = TextStyle(Apercu, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
val Caption = TextStyle(Apercu, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
val Tab = TextStyle(Apercu, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Apercu, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 17.sp) // Medium, not Bold
val TimerLarge = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 56.sp, lineHeight = 56.sp, letterSpacing = (-0.5).sp)
val StreakNumber = TextStyle(Apercu, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 22.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val HeadspaceTypography = Typography(
headlineLarge = HeadspaceText.GreetingHero,
headlineMedium = HeadspaceText.ScreenTitle,
titleLarge = HeadspaceText.MedTitle,
titleMedium = HeadspaceText.CardTitle,
bodyMedium = HeadspaceText.Body,
labelSmall = HeadspaceText.Tab,
)
Set TimerLarge and StreakNumber digits to align by wrapping the host in CompositionLocalProvider(LocalTextStyle …) with TextStyle(fontFeatureSettings = "tnum") — Compose has no monospacedDigit() shorthand; fontFeatureSettings = "tnum" is the equivalent.
3. Signature Components
Breathing Sphere (the hero component)
Headspace’s signature move. Drive the 4-2-4-2 cadence with a coroutine + Animatable rather than rememberInfiniteTransition so the hold phases are exact. Pure tween easing — never a spring (springs feel digital and break the meditative tone).
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.EaseInOut
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun BreathingSphere(
diameter: Dp,
modifier: Modifier = Modifier,
animate: Boolean = true, // pass false when Reduce Motion is on
) {
val scale = remember { Animatable(1f) }
LaunchedEffect(animate) {
if (!animate) { scale.snapTo(1f); return@LaunchedEffect }
while (true) {
scale.animateTo(1.04f, tween(4000, easing = EaseInOut)) // 4s inhale
kotlinx.coroutines.delay(2000) // 2s hold high
scale.animateTo(0.96f, tween(4000, easing = EaseInOut)) // 4s exhale
scale.snapTo(1f)
kotlinx.coroutines.delay(2000) // 2s hold neutral
}
}
Canvas(
modifier
.size(diameter)
.graphicsLayer { scaleX = scale.value; scaleY = scale.value }
) {
val r = size.minDimension / 2f
drawCircle(
brush = Brush.radialGradient(
colorStops = arrayOf(
0.0f to HeadspaceColors.Aurora1, // top-left highlight
0.70f to HeadspaceColors.Marigold,
1.0f to HeadspaceColors.MarigoldPressed, // bottom-right shadow
),
center = Offset(size.width * 0.32f, size.height * 0.32f),
radius = r * 1.4f,
),
radius = r,
)
drawCircle(
color = HeadspaceColors.InkBrown.copy(alpha = 0.08f),
radius = r,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 1.dp.toPx()),
)
}
}
Aurora Gradient Background (Today hero)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
@Composable
fun AuroraBackground(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) {
Box(
modifier.background(
Brush.verticalGradient(
listOf(HeadspaceColors.Aurora1, HeadspaceColors.Aurora2, HeadspaceColors.Aurora3)
)
),
content = content,
)
}
Today Hero
import androidx.compose.material3.Text
@Composable
fun TodayHero(greeting: String, vibe: String) {
AuroraBackground(Modifier.fillMaxWidth().height(280.dp)) {
Column(
Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(start = 24.dp, end = 24.dp, top = 16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(greeting, style = HeadspaceText.GreetingHero, color = HeadspaceColors.InkBrown)
Text(
"Today's vibe: $vibe",
style = HeadspaceText.Body,
color = HeadspaceColors.InkBrown.copy(alpha = 0.8f),
)
}
}
}
Primary Action Button (gentle warm glow)
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun HeadspaceCTAButton(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "ctaScale")
val haptics = LocalHapticFeedback.current
val shape = RoundedCornerShape(28.dp) // full pill on a 56.dp button
Box(
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.scale(scale)
.height(56.dp)
.shadow(16.dp, shape, spotColor = HeadspaceColors.Marigold.copy(alpha = 0.25f)) // sunrise glow
.clip(shape)
.background(if (pressed) HeadspaceColors.MarigoldPressed else HeadspaceColors.Marigold)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ≈ iOS soft impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Text(title, style = HeadspaceText.Button, color = HeadspaceColors.SurfaceWhite)
}
}
Meditation Session Card
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
@Composable
fun MeditationCard(
illustrationUrl: String,
tag: String,
title: String,
host: String,
duration: String,
modifier: Modifier = Modifier,
) {
Box(
modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.height(200.dp)
.shadow(16.dp, RoundedCornerShape(24.dp), spotColor = HeadspaceColors.InkBrown.copy(alpha = 0.08f))
.clip(RoundedCornerShape(24.dp)),
) {
AsyncImage(
model = illustrationUrl,
contentDescription = null,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
Box(
Modifier.matchParentSize().background(
Brush.verticalGradient(listOf(Color.Transparent, HeadspaceColors.InkBrown.copy(alpha = 0.6f)))
)
)
Column(
Modifier.align(Alignment.BottomStart).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
Modifier
.clip(RoundedCornerShape(percent = 50))
.background(Color.White.copy(alpha = 0.25f))
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(tag, style = HeadspaceText.TagPill, color = Color.White)
}
Text(title, style = HeadspaceText.MedTitle, color = Color.White)
Text(
"$host · $duration",
style = HeadspaceText.Meta,
color = Color.White.copy(alpha = 0.85f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
}
Quick Action Tile
@Composable
fun QuickActionTile(
tileColor: Color,
illustrationUrl: String,
title: String,
subtitle: String,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "tileScale")
val haptics = LocalHapticFeedback.current
Column(
Modifier
.scale(scale)
.size(width = 140.dp, height = 180.dp)
.clip(RoundedCornerShape(20.dp))
.background(tileColor)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = illustrationUrl,
contentDescription = null,
modifier = Modifier.size(64.dp),
)
Text(title, style = HeadspaceText.CardTitle, color = HeadspaceColors.InkBrown)
Text(subtitle, style = HeadspaceText.Meta, color = HeadspaceColors.InkBrown.copy(alpha = 0.7f))
}
}
Player Controls
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay10
import androidx.compose.material.icons.filled.Forward10
import androidx.compose.material3.Icon
@Composable
fun PlayerControls(
isPlaying: Boolean,
onPlayPause: () -> Unit,
onSkipBack: () -> Unit,
onSkipForward: () -> Unit,
) {
val haptics = LocalHapticFeedback.current
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircleControl(Icons.Filled.Replay10, "Skip back 15 seconds", onSkipBack)
Box(
Modifier
.size(72.dp)
.clip(CircleShape)
.background(HeadspaceColors.Marigold)
.shadow(16.dp, CircleShape, spotColor = HeadspaceColors.Marigold.copy(alpha = 0.25f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
onPlayPause()
},
contentAlignment = Alignment.Center,
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = HeadspaceColors.SurfaceWhite,
modifier = Modifier.size(28.dp),
)
}
CircleControl(Icons.Filled.Forward10, "Skip forward 15 seconds", onSkipForward)
}
}
@Composable
private fun CircleControl(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit,
) {
Box(
Modifier
.size(48.dp)
.clip(CircleShape)
.background(HeadspaceColors.OffCream)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = label, tint = HeadspaceColors.InkBrown, modifier = Modifier.size(22.dp))
}
}
Streak Ring (Profile)
@Composable
fun StreakRing(currentStreak: Int, goal: Int = 30, modifier: Modifier = Modifier) {
val target = (currentStreak.toFloat() / goal).coerceIn(0f, 1f)
val progress = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(target) {
progress.animateTo(target, tween(1200, easing = EaseInOut))
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // soft success
}
Box(Modifier.size(120.dp), contentAlignment = Alignment.Center) {
Canvas(Modifier.fillMaxSize()) {
val sw = 8.dp.toPx()
drawCircle(HeadspaceColors.OffCream, style = androidx.compose.ui.graphics.drawscope.Stroke(sw))
drawArc(
color = HeadspaceColors.Marigold,
startAngle = -90f,
sweepAngle = 360f * progress.value,
useCenter = false,
style = androidx.compose.ui.graphics.drawscope.Stroke(sw, cap = androidx.compose.ui.graphics.StrokeCap.Round),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("$currentStreak", style = HeadspaceText.TimerLarge, color = HeadspaceColors.InkBrown)
Text("days", style = HeadspaceText.Meta, color = HeadspaceColors.InkBrownSoft)
}
}
}
Mood Tag Row
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.Spring
@Composable
fun MoodTag(emoji: String, label: String, selected: Boolean, onToggle: () -> Unit) {
val scale by animateFloatAsState(
if (selected) 1.05f else 1f,
spring(dampingRatio = 0.7f, stiffness = Spring.StiffnessMedium),
label = "moodScale",
)
val haptics = LocalHapticFeedback.current
Column(
Modifier
.scale(scale)
.size(width = 80.dp, height = 88.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (selected) HeadspaceColors.Butter else HeadspaceColors.OffCream)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ≈ iOS selection
onToggle()
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(emoji, fontSize = 32.sp)
Spacer(Modifier.height(6.dp))
Text(label, style = HeadspaceText.Meta, color = HeadspaceColors.InkBrown)
}
}
4. Breathing Meditation Sphere — the distinctive system
Headspace does no color extraction; its most distinctive interaction is the 234pt breathing sphere on a 12-second 4-2-4-2 cycle (≈234.dp on a 390.dp-wide phone — 60% of screen width). The implementation lives in §3 (BreathingSphere). The full play screen composes it with guidance text above and the countdown below, on a butter-cream canvas with 80.dp of clearance so the sphere feels uncrowded.
@Composable
fun PlayScreen(
guidanceText: String, // "Take a deep breath in..."
remaining: String, // "10:00"
isPlaying: Boolean,
onPlayPause: () -> Unit,
reduceMotion: Boolean = false,
) {
Column(
Modifier
.fillMaxSize()
.background(HeadspaceColors.ButterCream)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
guidanceText,
style = HeadspaceText.Guidance,
color = HeadspaceColors.InkBrown,
modifier = Modifier.padding(bottom = 80.dp),
)
BreathingSphere(diameter = 234.dp, animate = !reduceMotion)
Spacer(Modifier.height(80.dp))
Text(remaining, style = HeadspaceText.TimerLarge, color = HeadspaceColors.InkBrown)
Spacer(Modifier.height(48.dp))
PlayerControls(isPlaying, onPlayPause, {}, {})
}
}
A thin progress arc renders at the very top of the screen (Canvas drawArc, Sage #7E9F4B fill over a Warm Gray #E8DFC9 track, 2.dp stroke, round caps) — overlay it in a Box above the Column.
5. Navigation (Bottom Tab Bar)
Use Material 3 NavigationBar. Headspace’s iOS tab bar is a butter-cream surface (Sleep mode switches to dusk); Android has no first-class live blur, so use a fully opaque canvas surface. The active tint is Marigold on Today/Meditate/Move/Profile, but Sage #7E9F4B on the Sleep tab — the only tab with its own color.
import androidx.compose.material.icons.filled.WbSunny
import androidx.compose.material.icons.filled.Spa
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.DirectionsWalk
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.*
@Composable
fun HeadspaceBottomBar(selected: Int, onSelect: (Int) -> Unit, sleepMode: Boolean) {
val items = listOf(
"Today" to Icons.Filled.WbSunny,
"Meditate" to Icons.Filled.Spa,
"Sleep" to Icons.Filled.Bedtime,
"Move" to Icons.Filled.DirectionsWalk,
"Profile" to Icons.Filled.Person,
)
val haptics = LocalHapticFeedback.current
NavigationBar(
containerColor = if (sleepMode) HeadspaceColors.DuskCanvas else HeadspaceColors.ButterCream,
tonalElevation = 0.dp,
) {
items.forEachIndexed { i, (label, icon) ->
val isSleepTab = i == 2
val activeTint = if (isSleepTab) HeadspaceColors.SageMoss else HeadspaceColors.Marigold
NavigationBarItem(
selected = selected == i,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onSelect(i)
},
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = HeadspaceText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = activeTint,
selectedTextColor = activeTint,
unselectedIconColor = HeadspaceColors.InkBrownMute,
unselectedTextColor = HeadspaceColors.InkBrownMute,
indicatorColor = Color.Transparent, // Headspace has no Material pill
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Breathing sphere | Animatable + coroutine: tween(4000, EaseInOut) to 1.04, delay(2000), tween(4000, EaseInOut) to 0.96, delay(2000) — no spring |
| Primary CTA tap | animateFloatAsState 1 → 0.98 + HapticFeedbackType.LongPress; Marigold glow shadow |
| Mood tag select | animateFloatAsState 1 → 1.05 with spring(dampingRatio = 0.7f) + Butter fill + TextHandleMove haptic |
| Streak ring reveal | Animatable.animateTo(progress, tween(1200, EaseInOut)) + soft success haptic |
| Tab switch | TextHandleMove haptic + Material crossfade between outlined/filled icons |
| Sleep mode transition | Crossfade the canvas Color from ButterCream to DuskCanvas over 600ms |
// Sleep mode canvas crossfade — visceral, like dusk falling
@Composable
fun rememberSleepCanvas(sleepMode: Boolean): Color {
val color by animateColorAsState(
targetValue = if (sleepMode) HeadspaceColors.DuskCanvas else HeadspaceColors.ButterCream,
animationSpec = tween(600, easing = EaseInOut),
label = "sleepCanvas",
)
return color
}
Haptics: Headspace’s vocabulary is always soft. Prefer LocalHapticFeedback (LongPress ≈ iOS .soft impact, TextHandleMove ≈ .selection). For a closer match to the iOS soft impact use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, 60). Never use a heavy/long vibration — it breaks the calm.
7. Icons
The SF Symbols map to Material Icons (androidx.compose.material:material-icons-extended). The illustration companions are bitmap assets, not glyphs — ship as drawables and render with Image(painterResource(...)).
| Component | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Today tab | sun.max / sun.max.fill |
Icons.Filled.WbSunny |
| Meditate tab | leaf / leaf.fill |
Icons.Filled.Spa |
| Sleep tab | moon / moon.fill |
Icons.Filled.Bedtime |
| Move tab | figure.walk / figure.walk.motion |
Icons.Filled.DirectionsWalk |
| Profile tab | person / person.fill |
Icons.Filled.Person |
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Skip back 15s | gobackward.15 |
Icons.Filled.Replay10 (custom 15s vector preferred) |
| Skip forward 15s | goforward.15 |
Icons.Filled.Forward10 (custom 15s vector preferred) |
| Settings | gearshape |
Icons.Filled.Settings |
| Search | magnifyingglass |
Icons.Filled.Search |
| Favorite | heart / heart.fill |
Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Streak flame | flame.fill |
Icons.Filled.LocalFireDepartment (Marigold) |
| Notifications | bell |
Icons.Filled.Notifications |
| Milestone trophy | trophy.fill |
Icons.Filled.EmojiEvents (Milestone Gold) |
The Headspace illustration companions (round-headed pastel characters) have no Material equivalent — bundle the PNG/vector assets and size them 80.dp inline, 240.dp for milestone reveals, 64.dp for quick-action thumbnails.
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion +
Animatableare comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity. The butter-cream canvas wants dark-content (WindowCompatisAppearanceLightStatusBars = true); in Sleep mode flip to light-content. ApplyModifier.statusBarsPadding()to the Today greeting andScaffoldinsets so the tab bar clears the gesture nav. - Font scaling:
sphonors the user’s font scale automatically — keep it on the greeting, body, guidance, and meditation titles. Pin layout-sensitive text (the 56.sp timer, 11.sp tab labels, tag pills, the streak number) by wrapping inCompositionLocalProvider(LocalDensity provides Density(LocalDensity.current.density, fontScale = 1f)). - TalkBack: the breathing sphere is one element —
Modifier.semantics { contentDescription = "Breathing meditation. Currently exhaling. 10 minutes remaining." }, updating the description each phase so TalkBack users can follow the breath. Merge meditation-card text withModifier.semantics(mergeDescendants = true). - Touch targets: Material guidance is 48.dp minimum. The 72.dp play button is well clear; the 48.dp skip circles already meet it; mood tags (80×88.dp) and tag pills are generous.
- Contrast: Ink Brown
#2E1A47on Butter Cream#FFF7E7passes WCAG AAA at all sizes. Marigold#FF6B35on Butter Cream passes AA at 17sp+ Medium — validate any small Marigold text and bump to Marigold Pressed#E55A2Bif targeting strict compliance. Inverted cream on dusk surfaces passes AAA. - Reduce Motion: pass
animate = falsetoBreathingSphereto render it static at scale 1.0; replace the streak-ring animate-in with an instant draw. HonorSettings.Global.ANIMATOR_DURATION_SCALE == 0f. - Material You: do not enable
dynamicLightColorScheme()— Headspace’s brand requires the fixed butter-cream canvas, Marigold accent, and the deliberate dusk Sleep palette regardless of wallpaper.