coderef

Clubhouse

Clubhouse's iOS app feels like a warm, well-lit hallway in someone's home rather than a tech product. The canvas is a soft, paper-like cream (#F2EFE4) — not white, not gray, a deliberately analog off-cream that reads like recycled…
DownloadRaw
Clubhouse

Build with the Clubhouse look.

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

Palette

At a glance

Theme
light
Typefaces
Inter, DM Serif Display, DM Serif Display Italic
Sections
8
Length
4,660 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Clubhouse

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

Clubhouse (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Clubhouse’s visual language to Android with Jetpack Compose (Material 3): a color token object, the serif/sans Typography pairing, paste-ready @Composables for the signature speaking-pulse avatar / room stage / raise-hand bar, 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 (Clubhouse’s warm cream hallway, serif titles, circular speaker faces, the emerald speaking pulse, the all-pill control language) while making everything idiomatic Android — NavigationBar instead of a UITabBar, ModalBottomSheet instead of a SwiftUI sheet, 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 avatar photos. No color extraction — Clubhouse’s palette is fixed warm tokens, so Palette is not needed. Do not enable Material You — the warm cream + emerald identity must hold regardless of wallpaper.

1. Color Tokens

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

object ChColors {
    // Light — the Hallway
    val Cream        = Color(0xFFF2EFE4)
    val CreamCard    = Color(0xFFFBF9F1)
    val CreamPressed = Color(0xFFEAE6D8)
    val DividerLight = Color(0xFFDDD8C8)

    // Dark — the Room (warm near-black, NOT slate)
    val Room         = Color(0xFF1A1A1A)
    val DarkSurface1 = Color(0xFF242220)
    val DarkSurface2 = Color(0xFF2E2B27)
    val DividerDark  = Color(0xFF3A372F)

    // Text
    val Ink          = Color(0xFF1A1A1A)
    val InkSecondary = Color(0xFF6B6453)
    val InkTertiary  = Color(0xFF9A9483)
    val CreamText    = Color(0xFFF2EFE4)
    val CreamText2   = Color(0xFFB8B2A0)
    val CreamText3   = Color(0xFF837D6D)

    // Brand — the single accent
    val Emerald      = Color(0xFF38B569)
    val EmeraldDeep  = Color(0xFF2E9A58)
    val EmeraldSoft  = Color(0x2E38B569) // 18% — pulse halo / tinted chip
    val EmeraldInk   = Color(0xFF07210F) // text on emerald

    // Semantic
    val Gold         = Color(0xFFE9C46A) // moderator
    val Leave        = Color(0xFFE5575C)
    val Scrim        = Color(0x80141210) // warm, NOT black

    // Avatar gradient palette (content color)
    val PeachA = Color(0xFFF4C77B); val PeachB = Color(0xFFE0A24B)
    val SageA  = Color(0xFF9FD8B4); val SageB  = Color(0xFF5FB484)
    val LavA   = Color(0xFFC9B6E8); val LavB   = Color(0xFF9C7FD0)
    val CoralA = Color(0xFFF2A9A0); val CoralB = Color(0xFFD9776B)
    val SkyA   = Color(0xFFA9C7E8); val SkyB   = Color(0xFF6E9CD0)
}

val chAvatarPairs = listOf(
    ChColors.PeachA to ChColors.PeachB,
    ChColors.SageA  to ChColors.SageB,
    ChColors.LavA   to ChColors.LavB,
    ChColors.CoralA to ChColors.CoralB,
    ChColors.SkyA   to ChColors.SkyB,
)
fun chAvatarFor(id: Int) = chAvatarPairs[kotlin.math.abs(id) % chAvatarPairs.size]

Wire it into both schemes. The light scheme is the warm cream hallway; the dark scheme is the warm Room — never a cold slate.

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

private val ChLight = lightColorScheme(
    primary        = ChColors.Emerald,
    onPrimary      = ChColors.EmeraldInk,
    background     = ChColors.Cream,
    onBackground   = ChColors.Ink,
    surface        = ChColors.CreamCard,
    onSurface      = ChColors.Ink,
    surfaceVariant = ChColors.CreamPressed,
    outline        = ChColors.DividerLight,
    error          = ChColors.Leave,
)

private val ChDark = darkColorScheme(
    primary        = ChColors.Emerald,        // identical accent in both modes
    onPrimary      = ChColors.EmeraldInk,
    background     = ChColors.Room,            // warm near-black
    onBackground   = ChColors.CreamText,
    surface        = ChColors.DarkSurface1,
    onSurface      = ChColors.CreamText,
    surfaceVariant = ChColors.DarkSurface2,
    outline        = ChColors.DividerDark,
    error          = ChColors.Leave,
)

@Composable
fun ClubhouseTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) ChDark else ChLight,
    typography = ChTypography,
    content = content,
)

2. Typography

Clubhouse pairs a serif display for titles with a sans for UI. Drop DM Serif Display (titles) + Inter (UI) TTFs in res/font/ — both SIL OFL.

// ui/theme/ChType.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.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val DMSerif = FontFamily(
    Font(R.font.dmserifdisplay_regular, FontWeight.Normal),
    Font(R.font.dmserifdisplay_italic,  FontWeight.Normal, FontStyle.Italic),
)
val Inter = FontFamily(
    Font(R.font.inter_regular,  FontWeight.Normal),
    Font(R.font.inter_medium,   FontWeight.Medium),
    Font(R.font.inter_semibold, FontWeight.SemiBold),
    Font(R.font.inter_bold,     FontWeight.Bold),
)

// Named ramp — mirrors DESIGN.md §3 (pt → sp 1:1)
object ChText {
    // Serif display — titles, topics, greetings, quotes
    val RoomTitle   = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.2).sp)
    val ScreenTitle = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
    val Quote       = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic, fontSize = 20.sp, lineHeight = 26.sp)

    // Sans (Inter) — every UI text
    val Section    = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val Subsection = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 21.sp)
    val Body       = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 16.sp, lineHeight = 24.sp)
    val Name       = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 18.sp)
    val Button     = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 16.sp, lineHeight = 16.sp)
    val Meta       = TextStyle(Inter, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 20.sp)
    val RoleTag    = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 14.sp)
    val Overline   = TextStyle(Inter, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.8.sp)
    val Tab        = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
}

// Map onto Material 3 slots
val ChTypography = Typography(
    headlineLarge = ChText.RoomTitle,
    headlineMedium = ChText.ScreenTitle,
    titleMedium   = ChText.Section,
    bodyMedium    = ChText.Body,
    labelSmall    = ChText.Tab,
)

3. Signature Components

Speaking-Pulse Avatar (the core atom)

import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@Composable
fun ChSpeakerAvatar(
    id: Int,
    initials: String,
    name: String,
    role: String? = null,
    isSpeaking: Boolean = false,
    isMuted: Boolean = false,
    isHost: Boolean = false,
    size: Dp = 60.dp,
) {
    val (a, b) = chAvatarFor(id)
    val transition = rememberInfiniteTransition(label = "pulse")
    val haloScale by transition.animateFloat(
        initialValue = 1f, targetValue = if (isSpeaking) 1.12f else 1f,
        animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse),
        label = "haloScale",
    )

    Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(7.dp)) {
        Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size + 16.dp)) {
            if (isSpeaking) {
                Box(
                    Modifier
                        .size(size + 16.dp)
                        .scale(haloScale)
                        .clip(CircleShape)
                        .background(ChColors.EmeraldSoft)
                )
            }
            Box(
                Modifier
                    .size(size)
                    .clip(CircleShape)
                    .background(Brush.linearGradient(listOf(a, b)))
                    .then(if (isSpeaking) Modifier.border(3.dp, ChColors.Emerald, CircleShape) else Modifier)
                    .alpha(if (isMuted) 0.55f else 1f),
                contentAlignment = Alignment.Center,
            ) {
                Text(initials, fontFamily = Inter, fontWeight = FontWeight.Bold,
                    fontSize = (size.value * 0.33f).sp, color = Color(0xFF2A2620))
            }
            if (isMuted) {
                Box(
                    Modifier.align(Alignment.BottomEnd).size(22.dp).clip(CircleShape)
                        .background(ChColors.DarkSurface2).border(2.dp, ChColors.Room, CircleShape),
                    contentAlignment = Alignment.Center,
                ) { Icon(Icons.Filled.MicOff, null, tint = ChColors.CreamText2, modifier = Modifier.size(11.dp)) }
            }
            if (isHost) {
                Box(
                    Modifier.align(Alignment.TopEnd).size(20.dp).clip(CircleShape)
                        .background(ChColors.DarkSurface2).border(2.dp, ChColors.Room, CircleShape),
                    contentAlignment = Alignment.Center,
                ) { Text("🎙️", fontSize = 11.sp) }
            }
        }
        Column(horizontalAlignment = Alignment.CenterHorizontally) {
            Text(name, style = ChText.Name, maxLines = 1,
                color = if (role == "Moderator") ChColors.Gold else ChColors.CreamText)
            if (role != null) Text(role, fontFamily = Inter, fontWeight = FontWeight.Medium,
                fontSize = 11.sp, color = ChColors.CreamText3)
        }
    }
}

Room Stage (grouped speaker grid)

import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items

data class ChSpeaker(val id: Int, val initials: String, val name: String, val role: String? = null,
                     val speaking: Boolean = false, val muted: Boolean = false, val host: Boolean = false)
data class ChGroup(val label: String, val speakers: List<ChSpeaker>)

@Composable
fun ChRoomStage(groups: List<ChGroup>) {
    LazyVerticalGrid(
        columns = GridCells.Fixed(4),
        modifier = Modifier.fillMaxSize().background(ChColors.Room),
        contentPadding = PaddingValues(horizontal = 16.dp),
        verticalArrangement = Arrangement.spacedBy(14.dp),
    ) {
        groups.forEach { group ->
            item(span = { GridItemSpan(maxLineSpan) }) {
                Text(group.label.uppercase(), style = ChText.Overline, color = ChColors.CreamText3,
                    modifier = Modifier.padding(start = 6.dp, top = 14.dp, bottom = 10.dp))
            }
            items(group.speakers, key = { it.id }) { s ->
                ChSpeakerAvatar(s.id, s.initials, s.name, s.role, s.speaking, s.muted, s.host)
            }
        }
    }
}

Raise-Hand Bar (footer)

import androidx.compose.animation.core.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun ChRaiseHandBar() {
    var raised by remember { mutableStateOf(false) }
    var waveTrigger by remember { mutableIntStateOf(0) }
    val haptics = LocalHapticFeedback.current

    val wave by animateFloatAsState(
        targetValue = 0f,
        animationSpec = keyframes {
            durationMillis = 600
            (-15f) at 100; 15f at 250; (-15f) at 400; 15f at 500; 0f at 600
        },
        label = "wave-$waveTrigger",
    )

    Row(
        Modifier
            .fillMaxWidth()
            .background(ChColors.Room)
            .drawTopHairline(ChColors.DividerDark)
            .padding(horizontal = 20.dp, vertical = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Row(
            Modifier
                .clip(RoundedCornerShape(50))
                .background(if (raised) ChColors.EmeraldSoft else ChColors.DarkSurface2)
                .clickable {
                    haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                    if (!raised) waveTrigger++
                    raised = !raised
                }
                .padding(horizontal = 20.dp, vertical = 12.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text("✋", fontSize = 18.sp, modifier = Modifier.rotate(if (raised) 0f else wave))
            Text(if (raised) "Hand raised" else "Raise hand", style = ChText.Button,
                color = if (raised) ChColors.Emerald else ChColors.CreamText)
        }

        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(22.dp)) {
            Box(Modifier.size(24.dp).clip(CircleShape).background(ChColors.EmeraldSoft), Alignment.Center) {
                Box(Modifier.size(14.dp).clip(CircleShape).background(ChColors.Emerald))
            }
            Icon(Icons.Filled.Mic, "Mic", tint = ChColors.CreamText2, modifier = Modifier.size(22.dp))
        }
    }
}

fun Modifier.drawTopHairline(color: Color) = drawWithContent {
    drawContent()
    drawLine(color, androidx.compose.ui.geometry.Offset(0f, 0f),
        androidx.compose.ui.geometry.Offset(size.width, 0f), strokeWidth = 1f)
}

Hallway Room Card

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults

@Composable
fun ChHallwayCard(club: String, isLive: Boolean, title: String,
                  avatars: List<Pair<Int, String>>, count: Int) {
    Card(
        shape = RoundedCornerShape(16.dp),
        colors = CardDefaults.cardColors(containerColor = ChColors.CreamCard),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
    ) {
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
            Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically) {
                Text(club, style = ChText.Meta.copy(fontWeight = FontWeight.SemiBold), color = ChColors.InkSecondary)
                Box(
                    Modifier.clip(RoundedCornerShape(50))
                        .background(if (isLive) ChColors.EmeraldSoft else ChColors.CreamPressed)
                        .padding(horizontal = 10.dp, vertical = 5.dp)
                ) {
                    Text(if (isLive) "● Open" else "Upcoming", style = ChText.RoleTag,
                        color = if (isLive) ChColors.Emerald else ChColors.InkSecondary)
                }
            }
            Text(title, style = ChText.RoomTitle.copy(fontSize = 20.sp), color = ChColors.Ink)
            Row(verticalAlignment = Alignment.CenterVertically) {
                avatars.take(5).forEachIndexed { i, (id, ini) ->
                    val (c1, c2) = chAvatarFor(id)
                    Box(
                        Modifier
                            .offset(x = if (i == 0) 0.dp else (-10 * i).dp)
                            .size(32.dp).clip(CircleShape)
                            .background(Brush.linearGradient(listOf(c1, c2)))
                            .border(2.dp, ChColors.CreamCard, CircleShape),
                        contentAlignment = Alignment.Center,
                    ) { Text(ini, fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, color = Color.White) }
                }
                Text("$count in the room", style = ChText.Meta,
                    modifier = Modifier.offset(x = (-10 * (avatars.size.coerceAtMost(5) - 1) + 16).dp))
            }
        }
    }
}

4. Primary Pill Button

import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults

@Composable
fun ChPrimaryButton(text: String, onClick: () -> Unit) {
    Button(
        onClick = onClick,
        shape = RoundedCornerShape(50),               // full pill — every Clubhouse button
        colors = ButtonDefaults.buttonColors(
            containerColor = ChColors.Emerald,
            contentColor = ChColors.EmeraldInk,
        ),
        contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp),
    ) {
        Text(text, style = ChText.Button)
    }
}

5. Navigation

Clubhouse has a 4-tab bottom strip and a full-screen modal Room. On Android model the strip as a NavigationBar (no colored pill — active is just ink/cream) and the Room as a full-screen destination that collapses to a persistent mini-bar.

import androidx.compose.material3.*
import androidx.compose.material.icons.outlined.*

@Composable
fun ChBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = ChColors.Cream, tonalElevation = 0.dp) {
        val items = listOf(
            "Hallway"  to Icons.Outlined.Home,
            "Explore"  to Icons.Outlined.Explore,
            "Activity" to Icons.Outlined.Notifications,
            "Profile"  to Icons.Outlined.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = ChText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = ChColors.Ink,        // ink, no colored tint
                    selectedTextColor   = ChColors.Ink,
                    unselectedIconColor = ChColors.InkTertiary,
                    unselectedTextColor = ChColors.InkTertiary,
                    indicatorColor      = Color.Transparent,   // NO Material pill — Clubhouse has none
                ),
            )
        }
    }
}

The collapsed Room mini-bar docks just above the NavigationBar as a Surface (DarkSurface1, 16.dp radius, 4.dp elevation) showing the truncated serif room title, the active speaker’s 28.dp circular avatar with its live Emerald pulse still breathing, and a trailing leave ✕ in Leave. Tapping re-expands the full-screen Room.

6. Motion

Clubhouse motion is calm — the speaking pulse is a slow ~0.9s breath; sheets slide; leaving is undramatic.

Moment Compose recipe
Speaking pulse rememberInfiniteTransition → animateFloat(1f→1.12f, infiniteRepeatable(tween(900), RepeatMode.Reverse)) on the halo scale
New speaker joins AnimatedVisibility scaleIn(initialScale = 0.8f, tween(200)) + fadeIn(); grid reflows animateItemPlacement(tween(200))
Raise hand ✋ animateFloat via keyframes (±15° ×2 over 600ms) + soft haptic; pill cross-fades to “Hand raised” emerald-tinted
Invited to speak celebratory ModalBottomSheet slide-up + medium haptic; avatar animates between groups
Room enter / collapse full-screen route slide-up tween(300); collapse slide-down to docked mini-bar tween(280) (pulse continues)
Tab switch Crossfade(tween(200))
Audience pill change instant select + selection haptic
// Speaking pulse — the canonical Clubhouse motion
val t = rememberInfiniteTransition(label = "pulse")
val s by t.animateFloat(1f, 1.12f,
    infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse), label = "halo")
Box(Modifier.scale(s).background(ChColors.EmeraldSoft, CircleShape))

Haptics: use LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on raise-hand, ping, and new-speaker; for “invited to speak” use a stronger pattern via LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONFIRM). Audience-pill change uses HapticFeedbackConstants.CLOCK_TICK. Leaving a room is silent — no haptic, no alarm; the voice is calm.

7. Icons

Clubhouse uses circular illustrated avatars (not icons) for people; the host pin is the 🎙️ emoji; “speaking” is never an icon — it is the emerald ring + halo. The closest first-party set is androidx.compose.material:material-icons-extended.

Purpose SF Symbol (iOS) Material Icon (Compose)
Collapse room chevron.down Icons.Filled.KeyboardArrowDown
Add people person.badge.plus Icons.Filled.PersonAdd
Share / more square.and.arrow.up / ellipsis Icons.Filled.IosShare / Icons.Filled.MoreHoriz
Mic on mic.fill Icons.Filled.Mic
Mic off / muted badge mic.slash.fill Icons.Filled.MicOff
Hallway (tab) house Icons.Outlined.Home
Explore (tab) safari Icons.Outlined.Explore
Activity (tab) bell Icons.Outlined.Notifications
Profile (tab) person.circle Icons.Outlined.AccountCircle
Search magnifyingglass Icons.Filled.Search
Reminder bell.badge Icons.Filled.NotificationsActive
Start a room (+) plus Icons.Filled.Add
Leave xmark Icons.Filled.Close
Host pin 🎙️ (emoji) “🎙️” text glyph (not an icon)
Speaking (no icon — emerald ring + halo) (no icon)

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; sheets + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the cream hallway wants dark-content system bars (light-content in the dark Room). The room footer / hand bar and the NavigationBar need Modifier.windowInsetsPadding(WindowInsets.navigationBars); the collapsed mini-bar docks within the safe area above the nav bar.
  • No Material You: do not enable dynamicColorScheme() — Clubhouse’s warm cream + emerald identity must hold regardless of wallpaper; there is one fixed accent and it means “this person has the floor”.
  • Warm dark mode: the dark scheme is the warm Room (#1A1A1A / #242220 / #2E2B27) — never let it drift to a neutral/blue #191919.
  • Font scaling: sp honors the user’s font scale — keep it on serif titles, section headers, body, speaker names. Pin layout-critical text (role tags, 11sp group overlines, 10sp tab labels, the “+N” cluster chip) by deriving from dp or CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Truncate names to 1 line.
  • TalkBack: a speaking avatar needs Modifier.semantics { contentDescription = "$name, speaking" } (the pulse must NOT be the only cue); muted “{name}, muted”; host “{name}, host”; the raise-hand pill toggles its description “Raise hand” ↔ “Hand raised”; group overlines are heading().
  • Touch targets: Material guidance is 48.dp. Speaker avatars are ≥60.dp (fine); give the mic toggle (22.dp) and top-bar icons (20.dp) a 48.dp hit area via padding; the raise-hand pill is ≥48.dp tall; the whole Hallway card is clickable.
  • Contrast: #1A1A1A on #F2EFE4 and #F2EFE4 on #1A1A1A pass AA; #07210F on #38B569 passes AA for the CTA; the 3dp emerald ring is supplemented by the TalkBack “speaking” string for non-color users.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the breathing speaking pulse with a static 3dp Emerald ring (no infinite scale); sheet slides become Crossfade; skip the hand-wave (just cross-fade the pill).
  • Warm scrim: ModalBottomSheet / dialog scrims use ChColors.Scrim (0x80141210, warm), never pure black — preserve the hallway warmth.
  • Dark mode: invert cream → warm Room; the Emerald #38B569 is identical in both schemes. Shadows are soft — Hallway cards use a barely-there 2.dp elevation on cream; in dark, separation comes from the warm #242220 surface, not shadow.
All mobile designs →

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