Vimeo (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Vimeo’s cinematic visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables for the player + Staff Pick feed, navigation, 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 (Vimeo’s near-black screening room, the single Vimeo Blue accent, the iconic Staff Pick gold star, the cinematic auto-fading player) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, ExoPlayer/Media3 instead ofAVPlayer,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, Coil 2.6+ for thumbnails, and androidx.media3 (ExoPlayer) for the player. Vimeo is dark-first; the dark scheme is canonical.
1. Color Tokens
// ui/theme/VimeoColors.kt
import androidx.compose.ui.graphics.Color
object VimeoColors {
// Canvas & Surfaces (Dark — primary)
val Canvas = Color(0xFF0D0E12) // near-black blue ink — NOT pure black
val Surface1 = Color(0xFF16181F)
val Surface2 = Color(0xFF1F222B)
val Divider = Color(0xFF262A34)
// Canvas & Surfaces (Light — rare parity)
val LightCanvas = Color(0xFFFFFFFF)
val LightSurface1 = Color(0xFFF5F6F8)
val LightDivider = Color(0xFFE2E5EA)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFFAEB6C2)
val TextTertiary = Color(0xFF6B7280)
val OnBlue = Color(0xFF04121A)
// Brand (single accent)
val Blue = Color(0xFF00ADEF)
val BlueLegacy = Color(0xFF1AB7EA)
val BluePressed = Color(0xFF008FC4)
// Accent & curation
val StaffGold = Color(0xFFFFD24C) // Staff Pick badge ONLY
val PlusPurple = Color(0xFF8B5CF6)
// Semantic
val Success = Color(0xFF2ECC71)
val Error = Color(0xFFFF4D4F)
val Live = Color(0xFFFF2D55)
// Player scrim
val Scrim = Color(0xD90D151D) // rgba(8,21,29,0.85)
}
Wire it into both schemes. Vimeo is dark-first — the dark scheme is the real product; light is parity only. Never enable Material You dynamicColorScheme() — the brand has exactly one accent (Vimeo Blue) and it must hold regardless of wallpaper.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
private val VimeoDark = darkColorScheme(
primary = VimeoColors.Blue,
onPrimary = VimeoColors.OnBlue,
background = VimeoColors.Canvas,
onBackground = VimeoColors.TextPrimary,
surface = VimeoColors.Surface1,
onSurface = VimeoColors.TextPrimary,
surfaceVariant = VimeoColors.Surface2,
onSurfaceVariant = VimeoColors.TextSecondary,
outline = VimeoColors.Divider,
error = VimeoColors.Error,
)
private val VimeoLight = lightColorScheme(
primary = VimeoColors.Blue,
onPrimary = VimeoColors.OnBlue,
background = VimeoColors.LightCanvas,
onBackground = Color(0xFF10131A),
surface = VimeoColors.LightSurface1,
onSurface = Color(0xFF10131A),
outline = VimeoColors.LightDivider,
error = VimeoColors.Error,
)
@Composable
fun VimeoTheme(
dark: Boolean = true, // dark-first; pass isSystemInDarkTheme() only if you support light parity
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) VimeoDark else VimeoLight,
typography = VimeoTypography,
content = content,
)
2. Typography
Vimeo’s UI face is Inter (SIL OFL — drop the TTFs in res/font/). Body weights 400–700; numeric values use tabular figures via a FontFeatureSetting.
// ui/theme/VimeoType.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.text.style.TextGeometricTransform
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),
)
// Tabular figures for all timecodes / counts
private val tnum = "tnum"
object VimeoText {
val Display = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
val VideoTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 23.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 22.sp)
val BodyRead = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 24.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Creator = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 18.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val Pill = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Overline = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.6.sp)
val Timecode = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp, fontFeatureSettings = tnum)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
}
val VimeoTypography = Typography(
headlineLarge = VimeoText.Display,
headlineMedium = VimeoText.ScreenTitle,
titleLarge = VimeoText.Section,
titleMedium = VimeoText.VideoTitle,
bodyMedium = VimeoText.Body,
labelSmall = VimeoText.Tab,
)
3. Signature Components
Cinematic Video Player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun VimeoPlayer(elapsed: String, total: String, progress: Float) {
var controls by remember { mutableStateOf(true) }
LaunchedEffect(controls) {
if (controls) { delay(3000); controls = false }
}
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(Brush.linearGradient(listOf(Color(0xFF16384A), Color(0xFF08151D))))
.pointerInput(Unit) { detectTapGestures { controls = !controls } },
) {
AnimatedVisibility(controls, enter = fadeIn(tween(200)), exit = fadeOut(tween(250)),
modifier = Modifier.align(Alignment.Center)) {
Box(
Modifier
.size(52.dp).clip(RoundedCornerShape(50))
.background(VimeoColors.Canvas.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.PlayArrow, contentDescription = "Play",
tint = Color.White, modifier = Modifier.size(22.dp))
}
}
AnimatedVisibility(controls, enter = fadeIn(tween(200)), exit = fadeOut(tween(250)),
modifier = Modifier.align(Alignment.BottomCenter)) {
Row(
Modifier
.fillMaxWidth().height(34.dp)
.background(Brush.verticalGradient(listOf(Color.Transparent, VimeoColors.Scrim)))
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(elapsed, style = VimeoText.Timecode, color = Color.White)
ScrubBar(progress, Modifier.weight(1f))
Text(total, style = VimeoText.Timecode, color = Color.White)
Box(
Modifier
.clip(RoundedCornerShape(3.dp))
.padding(0.dp),
) {
Text("HD", style = VimeoText.Tab.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp),
color = Color.White, modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp))
}
}
}
}
}
@Composable
fun ScrubBar(progress: Float, modifier: Modifier = Modifier) {
Box(modifier.height(16.dp), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)).background(Color.White.copy(alpha = 0.22f)))
Box(Modifier.fillMaxWidth(progress).height(3.dp).clip(RoundedCornerShape(2.dp)).background(VimeoColors.Blue))
BoxWithConstraints {
Box(
Modifier
.offset(x = maxWidth * progress - 4.5.dp)
.size(9.dp).clip(RoundedCornerShape(50)).background(Color.White),
)
}
}
}
Bind the visual to androidx.media3.exoplayer.ExoPlayer; render frames into an AndroidView { PlayerView(it).apply { useController = false } } so the Compose overlay above owns all chrome. Use resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT — never crop a filmmaker’s frame.
Staff Pick Badge
import androidx.compose.material.icons.filled.Star
@Composable
fun StaffPickBadge(compact: Boolean = false, modifier: Modifier = Modifier) {
if (compact) {
Box(
modifier.size(16.dp).clip(RoundedCornerShape(50)).background(VimeoColors.StaffGold),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Star, null, tint = VimeoColors.OnBlue, modifier = Modifier.size(9.dp)) }
} else {
Row(
modifier
.clip(RoundedCornerShape(50))
.background(VimeoColors.StaffGold)
.padding(horizontal = 8.dp, vertical = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(Icons.Filled.Star, null, tint = VimeoColors.OnBlue, modifier = Modifier.size(10.dp))
Text("Staff Pick", style = VimeoText.Pill, color = VimeoColors.OnBlue)
}
}
}
Curated Watch Feed Row
@Composable
fun WatchFeedRow(title: String, creator: String, stats: String, duration: String) {
Row(
Modifier.fillMaxWidth().padding(bottom = 14.dp),
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
Box(
Modifier
.width(124.dp).aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp))
.background(Brush.linearGradient(listOf(Color(0xFF4A6B7C), Color(0xFF1E323D)))),
) {
StaffPickBadge(compact = true, Modifier.align(Alignment.TopStart).padding(5.dp))
Text(
duration, style = VimeoText.Tab.copy(fontWeight = FontWeight.Bold, fontSize = 9.sp),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd).padding(5.dp)
.clip(RoundedCornerShape(3.dp)).background(Color.Black.copy(alpha = 0.7f))
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
Column {
Text(title, style = VimeoText.Creator, color = VimeoColors.TextPrimary, maxLines = 2)
Text("by $creator", style = VimeoText.Meta, color = VimeoColors.TextTertiary,
modifier = Modifier.padding(top = 4.dp))
Text(stats, style = VimeoText.Meta, color = VimeoColors.TextTertiary,
modifier = Modifier.padding(top = 6.dp))
}
}
}
Follow Pill
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun FollowPill() {
var following by remember { mutableStateOf(false) }
val haptics = LocalHapticFeedback.current
Box(
Modifier
.clip(RoundedCornerShape(50))
.background(if (following) VimeoColors.Surface2 else VimeoColors.Blue)
.then(if (following) Modifier.border(1.dp, VimeoColors.Divider, RoundedCornerShape(50)) else Modifier)
.clickable { haptics.performHapticFeedback(HapticFeedbackType.LongPress); following = !following }
.padding(horizontal = 16.dp, vertical = 7.dp),
) {
Text(
if (following) "Following" else "Follow",
style = VimeoText.Pill,
color = if (following) VimeoColors.TextPrimary else VimeoColors.OnBlue,
)
}
}
4. Video Player Controls + Curated Feed
The player owns its own minimal chrome (center play, scrubber, timecodes, HD chip); the curated feed below is a LazyColumn of WatchFeedRows with a “STAFF PICKS” Overline header. Keep the player controls in a single AnimatedVisibility block so they fade together after 3s idle and return on tap — matching iOS exactly.
@Composable
fun WatchScreen(picks: List<Pick>) {
LazyColumn(
Modifier.fillMaxSize().background(VimeoColors.Canvas),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
) {
item { VimeoPlayer("1:24", "4:07", 0.42f); Spacer(Modifier.height(14.dp)) }
item {
Text("STAFF PICKS", style = VimeoText.Overline, color = VimeoColors.TextSecondary)
Spacer(Modifier.height(12.dp))
}
items(picks) { p -> WatchFeedRow(p.title, p.creator, p.stats, p.duration) }
}
}
data class Pick(val title: String, val creator: String, val stats: String, val duration: String)
5. Navigation
Vimeo has minimal chrome: a left-aligned wordmark top bar and a 5-slot bottom strip with an emphasized center Upload action. On Android model the strip as a NavigationBar; there is no Material tint pill — active is just Vimeo Blue.
@Composable
fun VimeoBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = VimeoColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Search" to Icons.Filled.Search,
"Upload" to Icons.Filled.PlayCircle, // center, emphasized
"Inbox" to Icons.Filled.Email,
"Profile" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
val isUpload = i == 2
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
Icon(icon, contentDescription = label,
tint = if (isUpload) VimeoColors.Blue else androidx.compose.ui.graphics.Color.Unspecified,
modifier = Modifier.size(if (isUpload) 26.dp else 22.dp))
},
label = if (isUpload) null else { { Text(label, style = VimeoText.Tab) } },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = VimeoColors.Blue,
selectedTextColor = VimeoColors.Blue,
unselectedIconColor = VimeoColors.TextTertiary,
unselectedTextColor = VimeoColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Vimeo has none
),
)
}
}
}
The top bar is a TopAppBar with the Vimeo wordmark as a leading title (NOT centered), a trailing search IconButton, and a notifications IconButton; containerColor = Color.Transparent over the player, opaque Canvas once content scrolls under it.
6. Motion
Vimeo motion is restrained and cinematic — controls breathe in and out of the frame; nothing bounces gratuitously.
| Moment | Compose recipe |
|---|---|
| Player controls fade | AnimatedVisibility fadeIn(tween(200)) / fadeOut(tween(250)); auto-hide via LaunchedEffect { delay(3000); controls = false } |
| Scrub knob grow | animateDpAsState 9.dp → 16.dp tween(120) on drag-start |
| Follow toggle | animateColorAsState background tween(200) + Crossfade label |
| Like heart | animateFloatAsState scale 1f → 1.25f → 1f via keyframes tween(260) |
| Thumbnail load | Coil crossfade(220) in ImageRequest |
| Tab switch | content Crossfade(tween(200)); active tint changes instantly |
| Sheet present | ModalBottomSheet default slide; 20.dp top corners |
| Pull to refresh | PullToRefreshContainer tinted VimeoColors.Blue |
// Like heart — the only spring-ish moment
val scale by animateFloatAsState(
targetValue = if (liked) 1f else 1f,
animationSpec = keyframes { durationMillis = 260; 1.25f at 130 },
label = "likePulse",
)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft tick on Follow, scrub-start, and Like. Use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) for tab selection. Auto-save / buffering is silent — no motion, no haptic.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. Ship the Vimeo “v” wordmark as a vector drawable (res/drawable/ic_vimeo_wordmark.xml); the Staff Pick mark is Icons.Filled.Star tinted StaffGold.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| Search (tab) | magnifyingglass |
Icons.Filled.Search |
| Upload (center) | play.circle.fill |
Icons.Filled.PlayCircle |
| Inbox (tab) | envelope |
Icons.Filled.Email |
| Profile (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Play | play.fill |
Icons.Filled.PlayArrow |
| Pause | pause.fill |
Icons.Filled.Pause |
| Staff Pick | star.fill |
Icons.Filled.Star |
| Notifications | bell |
Icons.Filled.Notifications |
| Like | heart / heart.fill |
Icons.Filled.FavoriteBorder / Icons.Filled.Favorite |
| Comment | bubble.left |
Icons.Filled.ChatBubbleOutline |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Watch Later | clock |
Icons.Filled.Schedule |
| Fullscreen | arrow.up.left.and.arrow.down.right |
Icons.Filled.Fullscreen |
| Captions | captions.bubble |
Icons.Filled.ClosedCaption |
| Settings (player) | gearshape |
Icons.Filled.Settings |
| More | ellipsis |
Icons.Filled.MoreHoriz |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21; Media3/ExoPlayer + modern motion comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the dark canvas wants light-content system bars. In landscape, hide system bars and the nav bar — go full-bleed cinema; the player controls must inset for the camera cutout and gesture nav. - Font scaling:
sphonors the user’s scale on titles, body, descriptions. Pin layout-critical text (timecodes, 10sp tab labels, overlines, pill text, duration/quality chips) — derive fromdpor fixfontScale = 1ffor those nodes. ApplyfontFeatureSettings = "tnum"to every timecode and count. - TalkBack: label the player node “Video player, {title}, {elapsed} of {total}”; expose the scrubber via
Modifier.semantics { progressBarRangeInfo = ...; setProgress { ... } }so it’s adjustable; label the Staff Pick badge “Staff Pick, editor selected”; the center Upload item getscontentDescription = "Upload a video". - Touch targets: Material guidance is 48.dp. Give the scrubber knob a 48.dp tall hit band, the 22.dp tab icons a 48.dp hit, the Follow pill ≥ 36.dp height.
- Contrast:
#FFFFFFand#AEB6C2on#0D0E12pass WCAG AA;#04121Aon#00ADEFand on#FFD24Cpass AA — never put white text on the gold Staff Pick badge. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the controls fade (instant toggle) and the like pulse (use a plain state change); keep the scrubber feedback. - Dark mode: the dark scheme is canonical (
#0D0E12, NOT pure black). Do not enable Material YoudynamicColorScheme()— Vimeo’s single-accent identity (Vimeo Blue + the sacred Staff Gold) must hold regardless of the device wallpaper. - Media: enable background playback + PiP via a
MediaSessionService;RESIZE_MODE_FITalways — Vimeo’s color-critical audience must never see a cropped or tinted frame.