coderef

Headspace

Headspace is a hand-drawn morning — warm, slow, and humanly imperfect. Where Nike Run Club is a stadium and Strava is a stopwatch, Headspace is a pottery studio.
DownloadRaw
Headspace

Build with the Headspace look.

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

Palette

At a glance

Theme
light
Typefaces
Apercu, Nunito
Sections
8
Length
4,582 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Headspace

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

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 — NavigationBar instead of a UITabBar, a translucent Surface instead of iOS blur, sp/dp instead of pt, and an Animatable-driven 4-2-4-2 breath cycle instead of SwiftUI’s withAnimation.

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 + Animatable are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity. The butter-cream canvas wants dark-content (WindowCompat isAppearanceLightStatusBars = true); in Sleep mode flip to light-content. Apply Modifier.statusBarsPadding() to the Today greeting and Scaffold insets so the tab bar clears the gesture nav.
  • Font scaling: sp honors 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 in CompositionLocalProvider(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 with Modifier.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 #2E1A47 on Butter Cream #FFF7E7 passes WCAG AAA at all sizes. Marigold #FF6B35 on Butter Cream passes AA at 17sp+ Medium — validate any small Marigold text and bump to Marigold Pressed #E55A2B if targeting strict compliance. Inverted cream on dusk surfaces passes AAA.
  • Reduce Motion: pass animate = false to BreathingSphere to render it static at scale 1.0; replace the streak-ring animate-in with an instant draw. Honor Settings.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.
All mobile designs →

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