Hulu (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Hulu’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 (Hulu’s near-black canvas, single electric-green accent, 16:9 dense rails, green progress bars) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,sp/dpinstead ofpt, Compose springs instead of UIKit haptics.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/HuluColors.kt
import androidx.compose.ui.graphics.Color
object HuluColors {
// Canvas & Surfaces
val Canvas = Color(0xFF0B0C0F)
val DeepBlack = Color(0xFF000000)
val Surface1 = Color(0xFF151619)
val Surface2 = Color(0xFF1E2024)
val Surface3 = Color(0xFF2A2D33)
val Divider = Color(0xFF2A2D33)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFA0A4AB)
val TextTertiary = Color(0xFF6B6F77)
// Brand
val Green = Color(0xFF1CE783)
val GreenPressed = Color(0xFF15B869)
val LiveRed = Color(0xFFF0476A)
val ErrorRed = Color(0xFFF0476A)
}
Wire it into a Material 3 darkColorScheme so ripples, dividers, and default component colors inherit the brand. Hulu 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 HuluScheme = darkColorScheme(
primary = HuluColors.Green,
onPrimary = HuluColors.Canvas, // intentional: near-black on bright green
background = HuluColors.Canvas,
onBackground = HuluColors.TextPrimary,
surface = HuluColors.Surface1,
onSurface = HuluColors.TextPrimary,
surfaceVariant = HuluColors.Surface2,
outline = HuluColors.Divider,
error = HuluColors.ErrorRed,
)
@Composable
fun HuluTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = HuluScheme, typography = HuluTypography, content = content)
2. Typography
Graphik 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 on Android.
// ui/theme/HuluType.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 Graphik = FontFamily(
Font(R.font.graphik_regular, FontWeight.Normal), // 400
Font(R.font.graphik_semibold, FontWeight.SemiBold), // 600
Font(R.font.graphik_bold, FontWeight.ExtraBold),// 800
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object HuluText {
val HeroTitle = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
val TitleLarge = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val RailHeader = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val SectionHeader = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.3).sp)
val EpisodeTitle = TextStyle(Graphik, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val TileTitle = TextStyle(Graphik, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Graphik, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Meta = TextStyle(Graphik, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val TileSubtitle = TextStyle(Graphik, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val LabelUpper = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp)
val ButtonSecondary = TextStyle(Graphik, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 19.sp)
val Tab = TextStyle(Graphik, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
val Badge = TextStyle(Graphik, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val HuluTypography = Typography(
headlineLarge = HuluText.TitleLarge,
headlineSmall = HuluText.RailHeader,
titleMedium = HuluText.TileTitle,
bodyMedium = HuluText.Body,
labelSmall = HuluText.Tab,
)
3. Signature Components
Primary Green Watch Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
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.PlayArrow
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.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
@Composable
fun HuluWatchButton(
title: String = "Watch",
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
animationSpec = spring(dampingRatio = 0.7f, stiffness = 600f),
label = "watchScale",
)
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.scale(scale)
.shadow(24.dp, RoundedCornerShape(8.dp), spotColor = HuluColors.Green.copy(alpha = 0.30f))
.clip(RoundedCornerShape(8.dp))
.background(if (pressed) HuluColors.GreenPressed else HuluColors.Green)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS light impact
onClick()
},
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null,
tint = HuluColors.Canvas, modifier = Modifier.size(18.dp))
Text(title, style = HuluText.Button, color = HuluColors.Canvas) // near-black, not white
}
}
Secondary Button (Details / Trailer)
@Composable
fun HuluSecondaryButton(text: 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 = "secScale")
Box(
modifier = modifier
.fillMaxWidth()
.height(52.dp)
.scale(scale)
.clip(RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = if (pressed) 0.20f else 0.12f))
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(text, style = HuluText.ButtonSecondary, color = HuluColors.TextPrimary)
}
}
16:9 Content Tile (with progress bar)
@Composable
fun HuluContentTile(
title: String,
subtitle: String,
artworkUrl: String,
progress: Float?, // 0f..1f or null
width: Dp,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 1.04f else 1f, label = "tileScale")
Column(
modifier = 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 = artworkUrl,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
if (progress != null) {
Box(
Modifier
.align(Alignment.BottomStart)
.padding(2.dp)
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp))
.background(Color.White.copy(alpha = 0.25f)),
) {
Box(
Modifier
.fillMaxWidth(progress)
.fillMaxHeight()
.background(HuluColors.Green),
)
}
}
}
Spacer(Modifier.height(6.dp))
Text(title, style = HuluText.TileTitle, color = HuluColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = HuluText.TileSubtitle, color = HuluColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Details Hero (darkened backdrop + green CTA)
@Composable
fun HuluDetailsHero(
title: String,
metadata: String,
backdropUrl: String,
onWatch: () -> Unit,
) {
Column(Modifier.fillMaxWidth().background(HuluColors.Canvas)) {
Box(Modifier.fillMaxWidth().height(280.dp)) {
AsyncImage(
model = backdropUrl,
contentDescription = "$title backdrop",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
0.4f to Color.Transparent,
1f to HuluColors.Canvas,
)
)
)
Column(Modifier.align(Alignment.BottomStart).padding(16.dp)) {
Text(title, style = HuluText.HeroTitle, color = HuluColors.TextPrimary)
Spacer(Modifier.height(6.dp))
Text(metadata, style = HuluText.Meta, color = HuluColors.TextSecondary)
}
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
HuluWatchButton(onClick = onWatch)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
HuluIconAction(Icons.Filled.Add, "My Stuff")
HuluIconAction(Icons.Filled.FileDownload, "Download")
HuluIconAction(Icons.Filled.IosShare, "Share")
}
}
}
}
@Composable
fun HuluIconAction(icon: ImageVector, label: String, active: Boolean = false) {
Column(
Modifier.widthIn(min = 44.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(icon, contentDescription = label,
tint = if (active) HuluColors.Green else HuluColors.TextPrimary,
modifier = Modifier.size(22.dp))
Text(label, style = HuluText.TileSubtitle, color = HuluColors.TextSecondary)
}
}
4. Live Badge
@Composable
fun HuluLiveBadge() {
val t = rememberInfiniteTransition(label = "live")
val alpha by t.animateFloat(
1f, 0.4f,
infiniteRepeatable(tween(700), RepeatMode.Reverse),
label = "dot",
)
Row(
Modifier
.clip(RoundedCornerShape(50))
.background(HuluColors.LiveRed)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(Modifier.size(6.dp).alpha(alpha).clip(CircleShape).background(Color.White))
Text("LIVE", style = HuluText.Badge, color = Color.White)
}
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Hulu’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque canvas surface. Active tint is Hulu Green — unlike many streamers, green IS the tab indicator here.
@Composable
fun HuluBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = HuluColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Hubs" to Icons.Filled.GridView,
"My Stuff" to Icons.Filled.Bookmark,
"Search" to Icons.Filled.Search,
"Account" to Icons.Filled.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 = HuluText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = HuluColors.Green, // green IS the indicator
selectedTextColor = HuluColors.Green,
unselectedIconColor = HuluColors.TextSecondary,
unselectedTextColor = HuluColors.TextSecondary,
indicatorColor = Color.Transparent, // no Material pill — Hulu has none
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Tile press scale-up | animateFloatAsState 1 → 1.04, tween(180) ease-out + focus shadow + green ring |
| Watch CTA tap | animateFloatAsState 1 → 0.97 with spring(dampingRatio = 0.7f), HapticFeedbackType.LongPress |
| Add to My Stuff | Animatable 1 → 1.15 → 1 over 250ms; HapticFeedbackType.LongPress |
| LIVE dot pulse | rememberInfiniteTransition alpha 1 → 0.4, tween(700) RepeatMode.Reverse |
| Details enter | AnimatedVisibility + slideInVertically { it / 8 } + fadeIn(tween(250)) on rails |
// Add-to-My-Stuff bounce
@Composable
fun MyStuffToggle(inList: Boolean, onToggle: () -> Unit) {
val scale = remember { Animatable(1f) }
LaunchedEffect(inList) {
scale.animateTo(1.15f, tween(120)); scale.animateTo(1f, tween(130))
}
Icon(
imageVector = if (inList) Icons.Filled.Check else Icons.Filled.Add,
contentDescription = if (inList) "In My Stuff" else "Add to My Stuff",
tint = if (inList) HuluColors.Green else HuluColors.TextPrimary,
modifier = Modifier.size(22.dp).scale(scale.value)
.clickable(remember { MutableInteractionSource() }, indication = null, onClick = onToggle),
)
}
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.
7. Icons
Hulu ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Hulu’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Play / Watch | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Skip Forward 10 | goforward.10 |
Icons.Filled.Forward10 |
| Skip Back 10 | gobackward.10 |
Icons.Filled.Replay10 |
| Add to My Stuff | plus / checkmark |
Icons.Filled.Add / Icons.Filled.Check |
| Download | arrow.down.to.line |
Icons.Filled.FileDownload |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Captions | captions.bubble |
Icons.Filled.ClosedCaption |
| AirPlay | airplayvideo |
Icons.Filled.Cast |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Hubs (tab) | square.grid.2x2.fill |
Icons.Filled.GridView |
| My Stuff (tab) | bookmark.fill |
Icons.Filled.Bookmark |
| Account (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion is comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the near-black canvas wantsWindowCompatlight-content system bars. ApplyScaffoldinsets so the tab bar clears gesture nav. - Font scaling:
sphonors the user’s font scale automatically — keep it on show titles, episode titles, synopsis. Pin layout-sensitive text (tab labels, scrubber timestamps, tile subtitles in dense rails) by deriving size fromdpor wrapping inCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: set
contentDescriptionon tiles (“Watch The Bear, Season 3 Episode 4, 24 minutes left”); merge tile title+subtitle withModifier.semantics(mergeDescendants = true)and expose progress viastateDescription. - Touch targets: Material guidance is 48.dp minimum. The 52.dp Watch CTA and 96.dp episode rows clear it; ensure 24.dp icon actions have
Modifier.size(48.dp)hit area via padding. - Contrast:
#A0A4ABon#0B0C0Fpasses WCAG AA at 13sp+. The bright green (#1CE783) with near-black (#0B0C0F) text exceeds AAA — keep that pairing; never white-on-green. Validate 11sp badge labels and bump toward#B6BAC0for strict compliance. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— Hulu’s brand requires the fixed#0B0C0Fcanvas and single electric-green accent regardless of wallpaper.