coderef

Twitch

Twitch's iOS app is a live-first surface built on a near-black canvas (#0E0E10) that keeps the focus on streamer video and the constant motion of chat.
DownloadRaw
Twitch

Build with the Twitch look.

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

Palette

At a glance

Theme
dark
Typefaces
Roobert
Sections
7
Length
3,642 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Twitch

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

Twitch (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Twitch’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, 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 (Twitch’s near-black canvas, strict purple-vs-live-red split, live thumbnail cards, docked/overlay chat) while making everything idiomatic Android — NavigationBar instead of a UITabBar, HazeBlur/scrim instead of UIBlurEffect, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.

1. Color Tokens

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

object TwitchColors {
    // Canvas & Surfaces
    val Canvas    = Color(0xFF0E0E10)
    val DeepBlack = Color(0xFF000000)
    val Surface1  = Color(0xFF18181B)
    val Surface2  = Color(0xFF1F1F23)
    val Surface3  = Color(0xFF2A2A2D)
    val Divider   = Color(0xFF2A2A2D)

    // Text
    val TextPrimary   = Color(0xFFEFEFF1)   // intentionally off-white, not pure #FFFFFF
    val TextSecondary = Color(0xFFADADB8)
    val TextTertiary  = Color(0xFF6F6F7B)

    // Brand & Liveness — strict split
    val Purple        = Color(0xFF9146FF)   // brand + primary action
    val PurplePressed = Color(0xFF772CE8)
    val LiveRed       = Color(0xFFEB0400)   // liveness ONLY
    val LiveRedPressed = Color(0xFFC20300)
    val OnlineGreen   = Color(0xFF00C16E)
    val ErrorRed      = Color(0xFFEB0400)
}

Wire it into a Material 3 darkColorScheme so ripples, dividers, and default components inherit the brand. Twitch is effectively dark-only; do not provide a light scheme.

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

private val TwitchScheme = darkColorScheme(
    primary        = TwitchColors.Purple,
    onPrimary      = Color.White,
    background     = TwitchColors.Canvas,
    onBackground   = TwitchColors.TextPrimary,
    surface        = TwitchColors.Surface1,
    onSurface      = TwitchColors.TextPrimary,
    surfaceVariant = TwitchColors.Surface2,
    outline        = TwitchColors.Divider,
    error          = TwitchColors.ErrorRed,
)

@Composable
fun TwitchTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = TwitchScheme, typography = TwitchTypography, content = content)

2. Typography

Roobert is a licensed brand typeface. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto) — its grotesque tone is the closest free substitute.

// ui/theme/TwitchType.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 Roobert = FontFamily(
    Font(R.font.roobert_regular,  FontWeight.Normal),   // 400
    Font(R.font.roobert_semibold, FontWeight.SemiBold), // 600
    Font(R.font.roobert_bold,     FontWeight.Bold),     // 700
)

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object TwitchText {
    val TitleLarge     = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
    val ChannelName    = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
    val SectionHeader  = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.3).sp)
    val StreamTitle    = TextStyle(Roobert, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
    val ChannelLabel   = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 15.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
    val Body           = TextStyle(Roobert, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 22.sp)
    val ChatMessage    = TextStyle(Roobert, fontWeight = FontWeight.Normal,   fontSize = 14.sp, lineHeight = 19.sp)
    val ChatUsername   = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 14.sp, lineHeight = 19.sp)
    val Meta           = TextStyle(Roobert, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp)
    val CardSubtitle   = TextStyle(Roobert, fontWeight = FontWeight.Normal,   fontSize = 12.sp, lineHeight = 16.sp)
    val LabelUpper     = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
    val Button         = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 15.sp, lineHeight = 19.sp, letterSpacing = 0.2.sp)
    val ButtonSecondary = TextStyle(Roobert, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 18.sp)
    val Tab            = TextStyle(Roobert, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
    val Badge          = TextStyle(Roobert, fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val TwitchTypography = Typography(
    headlineLarge = TwitchText.TitleLarge,
    headlineSmall = TwitchText.SectionHeader,
    titleMedium   = TwitchText.StreamTitle,
    bodyMedium    = TwitchText.Body,
    labelSmall    = TwitchText.Tab,
)

3. Signature Components

Primary Purple Follow / Subscribe Button

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Composable
fun TwitchFollowButton(
    following: Boolean,
    onToggle: () -> Unit,
    subscribe: Boolean = false,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(
        if (pressed) 0.97f else 1f,
        spring(dampingRatio = 0.7f, stiffness = 600f), label = "followScale",
    )
    val haptics = LocalHapticFeedback.current
    val label = if (following) (if (subscribe) "Subscribed" else "Following")
                else (if (subscribe) "Subscribe" else "Follow")
    val icon = if (following) Icons.Filled.Check else if (subscribe) Icons.Filled.Star else Icons.Filled.Favorite

    Row(
        modifier = modifier
            .height(40.dp)
            .scale(scale)
            .shadow(if (following) 0.dp else 18.dp, RoundedCornerShape(6.dp),
                    spotColor = TwitchColors.Purple.copy(alpha = 0.35f))
            .clip(RoundedCornerShape(6.dp))
            .background(
                if (following) (if (pressed) TwitchColors.Surface3 else TwitchColors.Surface2)
                else (if (pressed) TwitchColors.PurplePressed else TwitchColors.Purple)
            )
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress)
                onToggle()
            }
            .padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(6.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(icon, contentDescription = null,
            tint = if (following) TwitchColors.Purple else Color.White,
            modifier = Modifier.size(14.dp))
        Text(label,
            style = if (following) TwitchText.ButtonSecondary else TwitchText.Button,
            color = if (following) TwitchColors.TextPrimary else Color.White)
    }
}

LIVE Pill (pulsing) + Viewer-Count Pill

@Composable
fun TwitchLivePill(modifier: Modifier = Modifier) {
    val t = rememberInfiniteTransition(label = "live")
    val s by t.animateFloat(1f, 0.55f, infiniteRepeatable(tween(800), RepeatMode.Reverse), label = "dot")
    Row(
        modifier
            .height(22.dp)
            .clip(RoundedCornerShape(4.dp))
            .background(TwitchColors.LiveRed)
            .padding(horizontal = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(5.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(Modifier.size(6.dp).scale(s).alpha(s).clip(CircleShape).background(Color.White))
        Text("LIVE", style = TwitchText.Badge, color = Color.White)
    }
}

@Composable
fun TwitchViewerPill(count: String, modifier: Modifier = Modifier) {
    Row(
        modifier
            .height(22.dp)
            .clip(RoundedCornerShape(4.dp))
            .background(Color.Black.copy(alpha = 0.6f))
            .padding(horizontal = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(Icons.Filled.Person, contentDescription = null, tint = Color.White, modifier = Modifier.size(9.dp))
        Text(count, style = TwitchText.Badge, color = Color.White)
    }
}

Live Thumbnail Card

@Composable
fun TwitchLiveCard(
    title: String, channel: String, game: String, viewers: String,
    thumbUrl: String, avatarUrl: String, width: Dp,
    onClick: () -> Unit, modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 1.03f else 1f, label = "cardScale")

    Column(
        modifier
            .width(width)
            .scale(scale)
            .clickable(interaction, indication = null, onClick = onClick),
    ) {
        Box(Modifier.width(width).height(width * 9 / 16).clip(RoundedCornerShape(6.dp))) {
            AsyncImage(model = thumbUrl, contentDescription = title,
                modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
            TwitchLivePill(Modifier.align(Alignment.TopStart).padding(8.dp))
            TwitchViewerPill(viewers, Modifier.align(Alignment.BottomStart).padding(8.dp))
        }
        Spacer(Modifier.height(8.dp))
        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            AsyncImage(model = avatarUrl, contentDescription = null,
                modifier = Modifier.size(32.dp).clip(CircleShape), contentScale = ContentScale.Crop)
            Column(Modifier.weight(1f)) {
                Text(title, style = TwitchText.StreamTitle, color = TwitchColors.TextPrimary,
                    maxLines = 2, overflow = TextOverflow.Ellipsis)
                Text(channel, style = TwitchText.Meta, color = TwitchColors.TextSecondary)
                Text(game, style = TwitchText.CardSubtitle, color = TwitchColors.TextSecondary)
            }
        }
    }
}

Chat Message Row + Channel Live Ring

@Composable
fun TwitchChatRow(username: String, userColor: Color, message: String, mentioned: Boolean = false) {
    Row(
        Modifier
            .fillMaxWidth()
            .then(if (mentioned)
                Modifier.background(TwitchColors.Purple.copy(alpha = 0.20f))
                    .drawBehind { drawRect(TwitchColors.Purple, size = size.copy(width = 2.dp.toPx())) }
                else Modifier)
            .padding(horizontal = 12.dp, vertical = 4.dp),
    ) {
        Text(
            buildAnnotatedString {
                withStyle(SpanStyle(color = userColor, fontWeight = FontWeight.Bold)) { append("$username ") }
                withStyle(SpanStyle(color = TwitchColors.TextPrimary)) { append(message) }
            },
            style = TwitchText.ChatMessage,
        )
    }
}

@Composable
fun TwitchAvatarRing(url: String, live: Boolean, size: Dp = 44.dp) {
    Box(
        Modifier
            .border(2.dp, if (live) TwitchColors.LiveRed else TwitchColors.Purple, CircleShape)
            .padding(2.dp),
    ) {
        AsyncImage(model = url, contentDescription = null,
            modifier = Modifier.size(size).clip(CircleShape), contentScale = ContentScale.Crop)
    }
}

Theater-Mode Chat Overlay

@Composable
fun TwitchTheaterChatOverlay(messages: List<ChatLine>, hidden: Boolean) {
    AnimatedVisibility(
        visible = !hidden,
        enter = slideInHorizontally { it } + fadeIn(),
        exit = slideOutHorizontally { it } + fadeOut(),
        modifier = Modifier.fillMaxHeight().width(320.dp),
    ) {
        Column(Modifier.fillMaxSize().background(TwitchColors.Canvas.copy(alpha = 0.72f))) {
            // Android has no first-class live blur; the 72%-opaque scrim approximates iOS .ultraThinMaterial.
            LazyColumn(Modifier.weight(1f)) {
                items(messages) { m -> TwitchChatRow(m.user, m.color, m.text, m.mentionsMe) }
            }
            BasicTextField(
                value = "", onValueChange = {},
                modifier = Modifier.padding(12.dp).fillMaxWidth()
                    .clip(RoundedCornerShape(8.dp)).background(TwitchColors.Surface2).padding(12.dp),
            )
        }
    }
}

4. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. Twitch’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque canvas surface. Active tint is Twitch Purple — purple is the indicator.

@Composable
fun TwitchBottomBar(selected: Int, unreadNotifs: Boolean, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = TwitchColors.Canvas.copy(alpha = 0.94f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Following"     to Icons.Filled.Favorite,
            "Browse"        to Icons.Filled.VideoLibrary,
            "Search"        to Icons.Filled.Search,
            "Notifications" to Icons.Filled.Notifications,
            "Profile"       to Icons.Filled.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = {
                    if (i == 3 && unreadNotifs) {
                        BadgedBox(badge = { Badge(containerColor = TwitchColors.LiveRed) }) {
                            Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
                        }
                    } else {
                        Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
                    }
                },
                label = { Text(label, style = TwitchText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = TwitchColors.Purple,   // purple is the indicator
                    selectedTextColor   = TwitchColors.Purple,
                    unselectedIconColor = TwitchColors.TextSecondary,
                    unselectedTextColor = TwitchColors.TextSecondary,
                    indicatorColor      = Color.Transparent,
                ),
            )
        }
    }
}

5. Motion

Moment Compose recipe
LIVE pill pulse rememberInfiniteTransition scale+alpha 1 → 0.55, tween(800) RepeatMode.Reverse
Card press scale-up animateFloatAsState 1 → 1.03, tween(180) ease-out + focus shadow + purple ring
Follow tap animateFloatAsState 1 → 0.97 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress
Chat autoscroll new items animateItemPlacement(); LazyColumn animateScrollToItem(last) unless user scrolled up
Viewer count AnimatedContent with slideInVertically/slideOutVertically (odometer feel)
Theater toggle AnimatedVisibility + slideInHorizontally { it } + fadeIn() on the chat overlay

Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(8, ...) to approximate iOS’s .light impact.

6. Icons

Twitch ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Twitch’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).

Purpose SF Symbol (iOS) Material Icon (Compose)
Follow heart / heart.fill Icons.Outlined.FavoriteBorder / Icons.Filled.Favorite
Subscribe star / star.fill Icons.Filled.Star
Followed check checkmark Icons.Filled.Check
Viewers person.fill Icons.Filled.Person
Play / Pause play.fill / pause.fill Icons.Filled.PlayArrow / Icons.Filled.Pause
Emote picker face.smiling Icons.Filled.EmojiEmotions
Send paperplane.fill Icons.Filled.Send
Share square.and.arrow.up Icons.Filled.IosShare
More ellipsis Icons.Filled.MoreHoriz
Search magnifyingglass Icons.Filled.Search
Following (tab) heart.fill Icons.Filled.Favorite
Browse (tab) play.square.stack.fill Icons.Filled.VideoLibrary
Notifications (tab) bell.fill Icons.Filled.Notifications
Profile (tab) person.crop.circle.fill Icons.Filled.AccountCircle

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; modern motion comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the near-black canvas wants WindowCompat light-content system bars. Apply Scaffold insets so the tab bar and chat input clear gesture nav.
  • Font scaling: sp honors the user’s font scale automatically — keep it on stream titles, channel names, chat. Pin the LIVE pill, viewer pills, and tab labels via dp-derived sizes; cap chat growth (fontScale clamp) to keep throughput readable.
  • TalkBack: announce live cards (“Live: Ranked grind by Channel, Just Chatting, 12.4K viewers”); wrap the chat list in Modifier.semantics { liveRegion = LiveRegionMode.Polite } so new messages are narrated (offer a mute).
  • Touch targets: Material guidance is 48.dp minimum. The 40.dp Follow CTA is borderline — add vertical padding to reach 48.dp hit; 64.dp channel rows clear it; pad chat send/emote icons to 48.dp.
  • Color split & color-blindness: never rely on purple-vs-red hue alone — the LIVE pill always carries the word “LIVE” + dot, the viewer pill carries a person glyph.
  • Contrast: #ADADB8 on #0E0E10 passes WCAG AA at 13sp+. #EFEFF1 is intentionally off-white and still exceeds AAA. White on #9146FF passes AA; white on #EB0400 passes AA at 11sp bold.
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — Twitch’s brand requires the fixed #0E0E10 canvas and the strict purple/live-red split 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