coderef

Tubi

Tubi's iOS app is built around a single, unmistakable idea: free movies and TV, paid for by ads, presented like a premium streamer.
DownloadRaw
Tubi

Build with the Tubi look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter, Archivo
Sections
7
Length
4,236 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Tubi

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

Tubi (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Tubi’s visual language to Android with Jetpack Compose (Material 3): a color token object, gradient brushes, a Typography set, paste-ready @Composables (hero, poster, content row, Live TV EPG), 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 (Tubi’s indigo-black canvas, the purple→magenta gradient as the brand, the dense free-poster rows, the white Play button) while making everything idiomatic Android — NavigationBar instead of a UITabBar, Brush.linearGradient instead of SwiftUI’s LinearGradient, LazyRow instead of a horizontal ScrollView, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+. Tubi is dark-only — no light scheme, no Material You dynamic color.

1. Color Tokens

// ui/theme/TubiColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Offset

object TubiColors {
    // Canvas & Surfaces (dark-only)
    val Canvas   = Color(0xFF0A0A2A)
    val Surface1 = Color(0xFF15153D)
    val Surface2 = Color(0xFF1E1E52)
    val Surface3 = Color(0xFF262666)
    val Divider  = Color(0xFF2A2A5C)

    // Brand gradient stops
    val Purple   = Color(0xFF7408FF)
    val Magenta  = Color(0xFFFF00FF)
    val Violet   = Color(0xFFA12BFF)
    val Pink     = Color(0xFFFF4FD8)

    // CTA / accent
    val PlayWhite   = Color(0xFFFFFFFF)
    val PlayPressed = Color(0xFFE6E6F2)
    val FreeYellow  = Color(0xFFFFD400)
    val OnWhite     = Color(0xFF0A0A2A)
    val FreeText    = Color(0xFF1A0A2A)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFB9B9D6)
    val TextTertiary  = Color(0xFF7A7AA0)

    // Semantic
    val Success = Color(0xFF1FD17B)
    val Error   = Color(0xFFFF4A6E)
    val Live    = Color(0xFFFF3B5C)
    val Rating  = Color(0xFFFFB020)

    // Gradient brushes
    val BrandBrush    = Brush.linearGradient(listOf(Purple, Magenta))
    val ProgressBrush = Brush.horizontalGradient(listOf(Purple, Magenta))
    val HeroScrim     = Brush.verticalGradient(
        0.40f to Color.Transparent,
        1.0f  to Canvas.copy(alpha = 0.95f),
    )
}

Wire it into a dark-only scheme. Tubi has no light mode and no brand accent that should harmonize with the wallpaper — never enable dynamicColorScheme().

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

private val TubiDark = darkColorScheme(
    primary        = TubiColors.Magenta,
    onPrimary      = TubiColors.TextPrimary,
    background     = TubiColors.Canvas,
    onBackground   = TubiColors.TextPrimary,
    surface        = TubiColors.Surface1,
    onSurface      = TubiColors.TextPrimary,
    surfaceVariant = TubiColors.Surface2,
    outline        = TubiColors.Divider,
    error          = TubiColors.Error,
)

@Composable
fun TubiTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = TubiDark,          // always dark
        typography  = TubiTypography,
        content     = content,
    )

2. Typography

Tubi’s brand face is a tight geometric grotesque; Inter at heavy weights is the closest free analog (SIL OFL). Drop the TTFs in res/font/. Titles are heavy (800–900), body 400; only badges are ALL-CAPS + positive-tracked.

// ui/theme/TubiType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val Inter = FontFamily(
    Font(R.font.inter_regular,    FontWeight.Normal),
    Font(R.font.inter_medium,     FontWeight.Medium),
    Font(R.font.inter_semibold,   FontWeight.SemiBold),
    Font(R.font.inter_bold,       FontWeight.Bold),
    Font(R.font.inter_extrabold,  FontWeight.ExtraBold),
    Font(R.font.inter_black,      FontWeight.Black),
)

object TubiText {
    val HeroTitle   = TextStyle(Inter, fontWeight = FontWeight.Black,      fontSize = 32.sp, lineHeight = 32.sp, letterSpacing = (-0.6).sp)
    val DetailTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold,  fontSize = 28.sp, lineHeight = 30.sp, letterSpacing = (-0.5).sp)
    val RowHeader   = TextStyle(Inter, fontWeight = FontWeight.ExtraBold,  fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(Inter, fontWeight = FontWeight.Bold,       fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.2).sp)
    val Body        = TextStyle(Inter, fontWeight = FontWeight.Normal,     fontSize = 16.sp, lineHeight = 24.sp)
    val CardTitle   = TextStyle(Inter, fontWeight = FontWeight.SemiBold,   fontSize = 15.sp, lineHeight = 17.sp)
    val Button      = TextStyle(Inter, fontWeight = FontWeight.ExtraBold,  fontSize = 15.sp, lineHeight = 15.sp)
    val Meta        = TextStyle(Inter, fontWeight = FontWeight.Medium,     fontSize = 13.sp, lineHeight = 17.sp)
    val Caption     = TextStyle(Inter, fontWeight = FontWeight.Medium,     fontSize = 12.sp, lineHeight = 16.sp)
    val Badge       = TextStyle(Inter, fontWeight = FontWeight.Bold,       fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.4.sp)
    val Tab         = TextStyle(Inter, fontWeight = FontWeight.SemiBold,   fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
}

val TubiTypography = Typography(
    headlineLarge  = TubiText.HeroTitle,
    headlineMedium = TubiText.RowHeader,
    titleMedium    = TubiText.Section,
    bodyMedium     = TubiText.Body,
    labelSmall     = TubiText.Tab,
)

3. Signature Components

Featured Hero

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage

@Composable
fun TubiHero(
    backdropUrl: String,
    badge: String,
    title: String,
    meta: String,
    onPlay: () -> Unit,
    onAdd: () -> Unit,
) {
    Box(
        Modifier
            .padding(horizontal = 14.dp)
            .fillMaxWidth()
            .height(230.dp)
            .clip(RoundedCornerShape(18.dp)),
    ) {
        AsyncImage(
            model = backdropUrl, contentDescription = null,
            modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop,
        )
        Box(Modifier.fillMaxSize().background(TubiColors.HeroScrim))
        Column(
            Modifier.fillMaxSize().padding(16.dp),
            verticalArrangement = Arrangement.SpaceBetween,
        ) {
            Box(
                Modifier
                    .clip(RoundedCornerShape(50))
                    .background(TubiColors.BrandBrush)
                    .padding(horizontal = 8.dp, vertical = 4.dp),
            ) { Text(badge.uppercase(), style = TubiText.Badge.copy(fontSize = 9.sp), color = Color.White) }

            Column {
                Text(title, style = TubiText.HeroTitle, color = TubiColors.TextPrimary)
                Text(meta, style = TubiText.Meta, color = TubiColors.TextSecondary, modifier = Modifier.padding(top = 7.dp))
                Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                    Button(
                        onClick = onPlay,
                        shape = RoundedCornerShape(8.dp),
                        colors = ButtonDefaults.buttonColors(containerColor = TubiColors.PlayWhite),
                        modifier = Modifier.weight(1f).height(48.dp),
                    ) {
                        Icon(Icons.Filled.PlayArrow, null, tint = TubiColors.OnWhite, modifier = Modifier.size(16.dp))
                        Spacer(Modifier.width(6.dp))
                        Text("Play Free", style = TubiText.Button, color = TubiColors.OnWhite)
                    }
                    FilledIconButton(
                        onClick = onAdd,
                        shape = RoundedCornerShape(8.dp),
                        colors = IconButtonDefaults.filledIconButtonColors(containerColor = Color.White.copy(alpha = 0.16f)),
                        modifier = Modifier.size(44.dp, 48.dp),
                    ) { Icon(Icons.Filled.Add, "Add to My List", tint = Color.White) }
                }
            }
        }
    }
}

Poster Card

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.clickable
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow

@Composable
fun TubiPoster(
    imageUrl: String,
    title: String,
    progress: Float? = null,   // 0f..1f
    onClick: () -> Unit,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 1.04f else 1f, tween(150), label = "posterScale")

    Box(
        Modifier
            .size(100.dp, 150.dp)
            .scale(scale)
            .shadow(8.dp, RoundedCornerShape(10.dp), spotColor = Color.Black.copy(alpha = 0.45f))
            .clip(RoundedCornerShape(10.dp))
            .then(if (pressed) Modifier.border(BorderStroke(2.dp, TubiColors.BrandBrush), RoundedCornerShape(10.dp)) else Modifier)
            .clickable(interaction, indication = null, onClick = onClick),
    ) {
        AsyncImage(model = imageUrl, contentDescription = title,
            modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)

        // FREE tag
        Box(
            Modifier
                .padding(6.dp)
                .clip(RoundedCornerShape(3.dp))
                .background(TubiColors.FreeYellow)
                .padding(horizontal = 5.dp, vertical = 2.dp),
        ) { Text("FREE", fontWeight = FontWeight.Black, fontSize = 8.sp, color = TubiColors.FreeText) }

        Text(
            title, style = TubiText.Caption.copy(fontWeight = FontWeight.Bold, fontSize = 11.sp),
            color = Color.White, maxLines = 2,
            modifier = Modifier.align(Alignment.BottomStart).padding(6.dp),
        )

        if (progress != null) {
            Box(
                Modifier
                    .align(Alignment.BottomStart)
                    .fillMaxWidth()
                    .height(4.dp)
                    .background(Color.White.copy(alpha = 0.16f)),
            ) {
                Box(
                    Modifier
                        .fillMaxWidth(progress)
                        .fillMaxHeight()
                        .background(TubiColors.ProgressBrush),
                )
            }
        }
    }
}

Content Row

import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items

@Composable
fun <T> TubiRow(
    title: String,
    items: List<T>,
    showSeeAll: Boolean = false,
    key: (T) -> Any,
    card: @Composable (T) -> Unit,
) {
    Column(Modifier.padding(top = 16.dp)) {
        Row(
            Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 0.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(title, style = TubiText.RowHeader, color = TubiColors.TextPrimary)
            Spacer(Modifier.weight(1f))
            if (showSeeAll) Text("See All ›", style = TubiText.Meta.copy(fontWeight = FontWeight.Bold), color = TubiColors.Pink)
        }
        Spacer(Modifier.height(10.dp))
        LazyRow(
            contentPadding = PaddingValues(horizontal = 16.dp),
            horizontalArrangement = Arrangement.spacedBy(10.dp),
        ) {
            items(items, key = key) { card(it) }
        }
    }
}

Live TV EPG Row

data class EpgProgram(val title: String, val timeRange: String, val isOnNow: Boolean)

@Composable
fun EpgRow(logoUrl: String, channelName: String, programs: List<EpgProgram>) {
    Row(
        Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        AsyncImage(
            model = logoUrl, contentDescription = null,
            modifier = Modifier.size(56.dp).clip(RoundedCornerShape(10.dp)).background(TubiColors.Surface1),
            contentScale = ContentScale.Crop,
        )
        Column(Modifier.weight(1f)) {
            Text(channelName, style = TubiText.CardTitle, color = TubiColors.TextPrimary)
            Spacer(Modifier.height(6.dp))
            LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                items(programs) { p ->
                    Box(
                        Modifier
                            .width(150.dp)
                            .clip(RoundedCornerShape(10.dp))
                            .background(TubiColors.Surface2),
                    ) {
                        if (p.isOnNow) {
                            Box(Modifier.fillMaxHeight().width(3.dp).background(TubiColors.BrandBrush))
                        }
                        Column(Modifier.padding(8.dp)) {
                            if (p.isOnNow) {
                                Row(verticalAlignment = Alignment.CenterVertically,
                                    horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                                    Box(Modifier.size(6.dp).clip(RoundedCornerShape(50)).background(TubiColors.Live))
                                    Text("LIVE", fontWeight = FontWeight.Black, fontSize = 9.sp, color = TubiColors.Live)
                                }
                            }
                            Text(p.title, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = TubiColors.TextPrimary)
                            Text(p.timeRange, fontWeight = FontWeight.Medium, fontSize = 11.sp, color = TubiColors.TextSecondary)
                        }
                    }
                }
            }
        }
    }
}

4. Navigation

Tubi has a 5-tab bottom strip; active is magenta with no Material tint pill.

@Composable
fun TubiBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = TubiColors.Canvas, tonalElevation = 0.dp) {
        val items = listOf(
            "Home"     to Icons.Filled.Home,
            "Search"   to Icons.Filled.Search,
            "Live TV"  to Icons.Filled.Tv,
            "My List"  to Icons.Filled.Bookmark,
            "Account"  to Icons.Filled.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, label, modifier = Modifier.size(22.dp)) },
                label = { Text(label, style = TubiText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = TubiColors.Magenta,
                    selectedTextColor   = TubiColors.Magenta,
                    unselectedIconColor = TubiColors.TextTertiary,
                    unselectedTextColor = TubiColors.TextTertiary,
                    indicatorColor      = Color.Transparent,  // no Material pill
                ),
            )
        }
    }
}

5. Motion

Tubi motion is punchy but brief — 120–400ms. The gradient acts as a “lit-by-brand” focus cue.

Moment Compose recipe
Poster press/focus animateFloatAsState scale 1f → 1.04f tween(150) + 2dp BrandBrush border while pressed
Hero cross-dissolve Crossfade(targetState = featuredIndex, animationSpec = tween(400)); ~6s auto-advance via LaunchedEffect
Continue-watching fill animateFloatAsState(progress, tween(500)) driving fillMaxWidth(fraction)
Add to My List icon Crossfade Add→Check + scale spring 1→1.2→1; soft haptic
Play transition AnimatedContent cross-fade into player tween(350)
Tab switch instant content swap; icon color animates to magenta tween(200)
Bottom sheet ModalBottomSheet slide-up; scrim Color(0xFF050510).copy(alpha = 0.7f)
// Hero auto cross-dissolve
var idx by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
    while (true) { delay(6000); idx = (idx + 1) % featured.size }
}
Crossfade(targetState = idx, animationSpec = tween(400), label = "hero") { i -> TubiHero(/* featured[i] */) }

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on Play, add-to-list confirm, and tab switch. Auto-resume of continue-watching is silent.

6. Icons

Closest first-party set is androidx.compose.material:material-icons-extended.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house.fill Icons.Filled.Home
Search (tab) magnifyingglass Icons.Filled.Search
Live TV (tab) tv Icons.Filled.Tv
My List (tab) bookmark.fill Icons.Filled.Bookmark
Account (tab) person.crop.circle Icons.Filled.AccountCircle
Play play.fill Icons.Filled.PlayArrow
Add to list plus → checkmark Icons.Filled.Add → Icons.Filled.Check
Download arrow.down.to.line Icons.Filled.Download
Share square.and.arrow.up Icons.Filled.Share
Back chevron.left Icons.AutoMirrored.Filled.ArrowBack
More like this rectangle.stack Icons.Filled.GridView
Cast & crew person.2 Icons.Filled.People
Live dot circle.fill Icons.Filled.Circle (tint #FF3B5C)
Restart gobackward Icons.Filled.Replay
Settings gearshape Icons.Filled.Settings

7. Minimum SDK & Accessibility Notes

  • minSdk 24, compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); force light-content system bars (the app is always dark). The hero backdrop should draw under the status bar; the bottom bar respects the navigation-bar inset.
  • Dark-only: never call dynamicColorScheme() and never build a light scheme — Tubi’s indigo-and-gradient identity is fixed regardless of wallpaper, and there is no brand accent to harmonize with one.
  • Font scaling: sp honors the user’s font scale — keep it on hero/detail titles, row headers, body, synopsis. Pin layout-sensitive text (badges, 10sp tab labels, progress labels, EPG time header) by deriving from dp or a fixed-fontScale LocalDensity.
  • Touch targets: Material guidance is 48.dp. The 22dp tab icons sit in full NavigationBarItem targets; give the small poster FREE tag and EPG controls 48.dp hit areas where tappable; primary buttons are 48.dp tall.
  • TalkBack: poster contentDescription must include “free to watch” (the yellow FREE tag is core information — never clearAndSetSemantics {} it away); continue-watching adds “, {n} minutes left”; EPG on-now block “{program}, live now on {channel}”.
  • Contrast: white on #0A0A2A and #1A0A2A on #FFD400 both pass WCAG AA; #B9B9D6 metadata on the canvas passes AA at 13sp+.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the poster press-scale and hero ken-burns; substitute a plain Crossfade and show the progress bar at final width.
  • The gradient is never the sole state signal: focus also scales (1.04) and adds a border so it’s perceivable for color-vision-deficient users.

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