coderef

Vimeo

Vimeo's iOS app is a screening room, not a feed. Where consumer video apps fight for attention with bright chrome, autoplay walls, and infinite scroll, Vimeo does the opposite: it builds a near-black theatre (#0D0E12) and then gets out of…
DownloadRaw
Vimeo

Build with the Vimeo look.

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

Palette

At a glance

Theme
dark
Typefaces
Inter
Sections
8
Length
4,043 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Vimeo

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

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 — NavigationBar instead of a UITabBar, ExoPlayer/Media3 instead of AVPlayer, dp/sp instead of pt.

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 BOM 2024.09+, Kotlin 2.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: sp honors the user’s scale on titles, body, descriptions. Pin layout-critical text (timecodes, 10sp tab labels, overlines, pill text, duration/quality chips) — derive from dp or fix fontScale = 1f for those nodes. Apply fontFeatureSettings = "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 gets contentDescription = "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: #FFFFFF and #AEB6C2 on #0D0E12 pass WCAG AA; #04121A on #00ADEF and on #FFD24C pass 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 You dynamicColorScheme() — 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_FIT always — Vimeo’s color-critical audience must never see a cropped or tinted frame.
All mobile designs →

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