coderef

Paramount+

Paramount+'s iOS app is a network-bundle streaming surface built on a deep, confident navy. It is dark-only — there is no light mode — anchored on a midnight-navy canvas (#0A0E2D) that subtly lightens to #0E1438 beneath hero artwork.
DownloadRaw
Paramount+

Build with the Paramount+ look.

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

Palette

At a glance

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

Add it to your project

Then prompt your agent

Design System · Paramount+

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

Paramount+ (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Paramount+'s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the brand-hubs row + hero billboard + content rows, 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 (the midnight-navy canvas, the brand-hubs row, the single Paramount+ Blue accent, the prestige hero) while making everything idiomatic Android — NavigationBar instead of a UITabBar, LazyRow instead of horizontal ScrollView, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for key art. Paramount+ is dark-only — no light scheme, no Material You dynamic color (the navy + single-blue identity must hold regardless of wallpaper).

1. Color Tokens

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

object ParamountColors {
    // Canvas & surfaces (dark-only product)
    val Canvas   = Color(0xFF0A0E2D) // midnight navy — never pure black
    val Canvas2  = Color(0xFF0E1438) // subtle lift under hero
    val Surface1 = Color(0xFF161C44)
    val Surface2 = Color(0xFF1F2655)
    val Divider  = Color(0xFF2A3266)

    // Text
    val TextPrimary   = Color(0xFFFFFFFF)
    val TextSecondary = Color(0xFFAEB4D6)
    val TextTertiary  = Color(0xFF6F77A6)

    // Brand (single accent)
    val Blue        = Color(0xFF0064FF)
    val BluePressed = Color(0xFF0052CC)
    val Sky         = Color(0xFF4D9DFF) // active-tab tint, eyebrows
    val Glass       = Color(0x24FFFFFF) // ~14% white

    // Semantic
    val Live    = Color(0xFFFF2D46)
    val Success = Color(0xFF1ED760)
    val ErrorR  = Color(0xFFFF453A)
    val Gold    = Color(0xFFF5C518)
}

Paramount+ is dark-first. Wire a single dark scheme; the light scheme is intentionally a copy (only legal/web views ever appear light, and those are out of scope).

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

private val ParamountDark = darkColorScheme(
    primary        = ParamountColors.Blue,        // single blue accent
    onPrimary      = ParamountColors.TextPrimary,
    background     = ParamountColors.Canvas,
    onBackground   = ParamountColors.TextPrimary,
    surface        = ParamountColors.Surface1,
    onSurface      = ParamountColors.TextPrimary,
    surfaceVariant = ParamountColors.Surface2,
    outline        = ParamountColors.Divider,
    error          = ParamountColors.ErrorR,
)

@Composable
fun ParamountTheme(content: @Composable () -> Unit) =
    MaterialTheme(
        colorScheme = ParamountDark,   // dark-only; do NOT use dynamicDarkColorScheme()
        typography  = ParamountTypography,
        content     = content,
    )

2. Typography

Paramount Sans is proprietary; ship Inter (SIL OFL) in res/font/. Hero/title heavy (800-900); pt → sp 1:1.

// ui/theme/ParamountType.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 ParamountText {
    val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.Black,     fontSize = 32.sp, lineHeight = 34.sp, letterSpacing = (-0.6).sp)
    val HeroTitle   = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 27.sp, letterSpacing = (-0.5).sp)
    val RowHeader   = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 22.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
    val CardTitle   = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 18.sp, lineHeight = 22.sp)
    val Body        = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 16.sp, lineHeight = 24.sp)
    val BodyReg     = TextStyle(Inter, fontWeight = FontWeight.Normal,    fontSize = 16.sp, lineHeight = 24.sp)
    val Caption     = TextStyle(Inter, fontWeight = FontWeight.Medium,    fontSize = 15.sp, lineHeight = 20.sp)
    val Meta        = TextStyle(Inter, fontWeight = FontWeight.Normal,    fontSize = 13.sp, lineHeight = 18.sp)
    val PosterCap   = TextStyle(Inter, fontWeight = FontWeight.Medium,    fontSize = 11.sp, lineHeight = 14.sp)
    val Tab         = TextStyle(Inter, fontWeight = FontWeight.SemiBold,  fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
    val Eyebrow     = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 1.6.sp)
    val Button      = TextStyle(Inter, fontWeight = FontWeight.Bold,      fontSize = 15.sp, lineHeight = 15.sp)
    val HubLabel    = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.3.sp)
    val Badge       = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 8.sp,  lineHeight = 8.sp,  letterSpacing = 0.5.sp)
}

val ParamountTypography = Typography(
    headlineLarge  = ParamountText.ScreenTitle,
    headlineMedium = ParamountText.HeroTitle,
    titleLarge     = ParamountText.RowHeader,
    titleMedium    = ParamountText.CardTitle,
    bodyMedium     = ParamountText.Body,
    labelSmall     = ParamountText.Tab,
)

3. Signature Components

Brand-Hubs Row

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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.animation.core.animateFloatAsState

data class Hub(val name: String, val gradient: List<Color>, val labelDark: Boolean = false)

@Composable
fun BrandHubsRow(hubs: List<Hub>, onSelect: (Hub) -> Unit) {
    LazyRow(
        contentPadding = PaddingValues(horizontal = 18.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        modifier = Modifier.padding(bottom = 12.dp),
    ) {
        items(hubs) { hub ->
            val interaction = remember { MutableInteractionSource() }
            val pressed by interaction.collectIsPressedAsState()
            val s by animateFloatAsState(if (pressed) 0.96f else 1f, label = "hub")
            Box(
                Modifier
                    .scale(s)
                    .heightIn(min = 40.dp)
                    .widthIn(min = 70.dp)
                    .clip(RoundedCornerShape(8.dp))
                    .background(Brush.linearGradient(hub.gradient))
                    .clickable(interaction, indication = null) { onSelect(hub) }
                    .padding(horizontal = 12.dp),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    hub.name,
                    style = ParamountText.HubLabel,
                    color = if (hub.labelDark) Color(0xFF1B1B1B) else Color.White,
                )
            }
        }
    }
}

Hero Billboard

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage

@Composable
fun HeroBillboard(
    artUrl: String,
    eyebrow: String,
    title: String,
    meta: String,
    onPlay: () -> Unit,
    onAdd: () -> Unit,
) {
    Box(Modifier.fillMaxWidth().height(332.dp)) {
        AsyncImage(
            model = artUrl, contentDescription = title,
            modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop,
        )
        Box(
            Modifier.matchParentSize().background(
                Brush.verticalGradient(
                    0.36f to Color.Transparent,
                    0.70f to ParamountColors.Canvas.copy(alpha = 0.72f),
                    1.0f  to ParamountColors.Canvas,
                )
            )
        )
        Column(
            Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 14.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            Text(eyebrow.uppercase(), style = ParamountText.Eyebrow, color = ParamountColors.Sky)
            Text(title, style = ParamountText.HeroTitle, color = ParamountColors.TextPrimary)
            Text(meta, style = ParamountText.Meta, color = ParamountColors.TextSecondary)
            Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                Button(
                    onClick = onPlay,
                    colors = ButtonDefaults.buttonColors(containerColor = ParamountColors.Blue, contentColor = Color.White),
                    shape = RoundedCornerShape(6.dp),
                    contentPadding = PaddingValues(horizontal = 28.dp, vertical = 13.dp),
                ) {
                    Icon(Icons.Filled.PlayArrow, null, modifier = Modifier.size(16.dp))
                    Spacer(Modifier.width(7.dp))
                    Text("Play", style = ParamountText.Button)
                }
                Surface(onClick = onAdd, color = ParamountColors.Glass, shape = RoundedCornerShape(6.dp)) {
                    Row(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
                        Icon(Icons.Filled.Add, null, tint = Color.White, modifier = Modifier.size(16.dp))
                        Spacer(Modifier.width(6.dp))
                        Text("My List", style = ParamountText.Body, color = Color.White)
                    }
                }
            }
        }
    }
}

Poster Card (LIVE / NEW)

import androidx.compose.animation.core.*
import androidx.compose.foundation.shape.RoundedCornerShape

@Composable
fun LiveFlag() {
    val t = rememberInfiniteTransition(label = "live")
    val a by t.animateFloat(1f, 0.4f, infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "dot")
    Row(
        Modifier.clip(RoundedCornerShape(4.dp)).background(ParamountColors.Live)
            .padding(horizontal = 6.dp, vertical = 3.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(4.dp),
    ) {
        Box(Modifier.size(5.dp).clip(CircleShape).background(Color.White.copy(alpha = a)))
        Text("LIVE", style = ParamountText.Badge, color = Color.White)
    }
}

@Composable
fun PosterCard(artUrl: String, title: String, live: Boolean = false, isNew: Boolean = false) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.97f else 1f, label = "poster")

    Column(Modifier.width(116.dp)) {
        Box(Modifier.size(116.dp, 164.dp).scale(scale).clip(RoundedCornerShape(8.dp))) {
            AsyncImage(model = artUrl, contentDescription = title,
                modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop)
            if (live) {
                Box(Modifier.align(Alignment.TopStart).padding(6.dp)) { LiveFlag() }
            } else if (isNew) {
                Box(
                    Modifier.align(Alignment.TopStart).padding(6.dp)
                        .clip(RoundedCornerShape(4.dp)).background(ParamountColors.Blue)
                        .padding(horizontal = 6.dp, vertical = 3.dp)
                ) { Text("NEW", style = ParamountText.Badge, color = Color.White) }
            }
        }
        Spacer(Modifier.height(6.dp))
        Text(title, style = ParamountText.PosterCap, color = ParamountColors.TextSecondary, maxLines = 1)
    }
}

Keep-Watching Progress + Live Badge

@Composable
fun WatchProgress(fraction: Float, label: String) {
    val w by animateFloatAsState(fraction, tween(400), label = "progress")
    Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
        Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp)).background(ParamountColors.Surface2)) {
            Box(Modifier.fillMaxWidth(w).height(4.dp).clip(RoundedCornerShape(2.dp)).background(ParamountColors.Blue))
        }
        Text(label, style = ParamountText.PosterCap, color = ParamountColors.TextSecondary)
    }
}

@Composable
fun LiveBadge() {
    val t = rememberInfiniteTransition(label = "liveBadge")
    val a by t.animateFloat(1f, 0.4f, infiniteRepeatable(tween(1200), RepeatMode.Reverse), label = "dot")
    Row(
        Modifier.clip(RoundedCornerShape(4.dp)).background(ParamountColors.Live)
            .padding(horizontal = 10.dp, vertical = 5.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(6.dp),
    ) {
        Box(Modifier.size(7.dp).clip(CircleShape).background(Color.White.copy(alpha = a)))
        Text("LIVE", style = ParamountText.Eyebrow.copy(letterSpacing = 0.6.sp), color = Color.White)
    }
}

4. Navigation

Paramount+ has a 4-tab bottom strip and a solid top bar with the hubs row pinned below it. On Android, model the strip as a NavigationBar. There is no tint pill — active is the sky-blue icon.

import androidx.compose.material.icons.filled.*

@Composable
fun ParamountBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = ParamountColors.Canvas.copy(alpha = 0.96f),
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"    to Icons.Filled.Home,
            "Live TV" to Icons.Filled.Tv,
            "Search"  to Icons.Filled.Search,
            "My List" to Icons.Filled.VideoLibrary,
        )
        items.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = { onSelect(i) },
                icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp)) },
                label = { Text(label, style = ParamountText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = ParamountColors.Sky,
                    selectedTextColor   = ParamountColors.Sky,
                    unselectedIconColor = ParamountColors.TextTertiary,
                    unselectedTextColor = ParamountColors.TextTertiary,
                    indicatorColor      = Color.Transparent, // no Material pill — Paramount+ has none
                ),
            )
        }
    }
}

The top bar is a solid Canvas Row (mountain logomark SVG + “Paramount” + blue “+” leading; search + profile trailing) with the BrandHubsRow pinned directly below it. Content rows are LazyRows with contentPadding = PaddingValues(horizontal = 18.dp) so the last card clips off the trailing edge. The detail page is a full-bleed AsyncImage with a back chevron on a 40%-black circle.

5. Motion

Paramount+ motion is prestige-quiet — 120-400ms ease-out, never bouncy.

Moment Compose recipe
Poster press collectIsPressedAsState() → animateFloatAsState(0.97f, tween()) on Modifier.scale
Live dot pulse rememberInfiniteTransition → animateFloat(1f, 0.4f, infiniteRepeatable(tween(1200), Reverse))
Hub re-skin Crossfade(targetState = activeHubId, animationSpec = tween(300)) over the browse content
Hero cross-fade (~8s) Crossfade(targetState = featuredIndex, animationSpec = tween(400))
Detail open shared-element via SharedTransitionLayout (Compose 1.7+) or AnimatedContent slide tween(300)
Progress fill animateFloatAsState(fraction, tween(400)) on fillMaxWidth(w)
Sheet present ModalBottomSheet (Material 3) — slide-up 300ms; scrim Color.Black.copy(alpha = 0.6f)
Row scroll LazyRow native fling — free scroll, no snap
// Hub re-skin — the canonical Paramount+ structural motion
Crossfade(targetState = activeHubId, animationSpec = tween(300), label = "hubReskin") { id ->
    HubBrowse(networkId = id)   // whole browse re-tinted into the network's brand color
}

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft confirm on hub switch and add-to-My-List; a lighter HapticFeedbackConstants.CLOCK_TICK (via LocalView) for poster selection. Playback start/stop is silent.

6. Icons

The closest first-party set is androidx.compose.material:material-icons-extended. The mountain logomark + “Paramount+” wordmark must be a vector drawable / ImageVector — it is not an icon-font glyph.

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house / house.fill Icons.Filled.Home
Live TV (tab) tv Icons.Filled.Tv
Search (tab) magnifyingglass Icons.Filled.Search
My List (tab) plus.rectangle.on.rectangle Icons.Filled.VideoLibrary
Play (CTA) play.fill Icons.Filled.PlayArrow
Add plus / checkmark Icons.Filled.Add / Icons.Filled.Check
Back chevron.left Icons.Filled.ArrowBack
More info info.circle Icons.Filled.InfoOutline
Download arrow.down.circle Icons.Filled.Download
Cast airplayvideo Icons.Filled.Cast
Profile person.crop.circle Icons.Filled.AccountCircle
Live dot.radiowaves.left.and.right Icons.Filled.SensorsOutlined
Trailer play.rectangle Icons.Filled.SmartDisplay
Schedule (Live) calendar Icons.Filled.CalendarMonth
Settings gearshape Icons.Filled.Settings
Share square.and.arrow.up Icons.Filled.IosShare

7. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; ModalBottomSheet + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the navy canvas wants light-content system bars in all states (the app is dark-only). Hero art bleeds under the status bar; the bottom bar respects the navigation bar inset.
  • Dark-only: ship only ParamountDark; do not call dynamicDarkColorScheme() — the navy + single-blue identity must hold regardless of wallpaper. Ignore isSystemInDarkTheme().
  • Font scaling: sp honors the user’s font scale — keep it on screen/hero titles, row headers, body, captions. Pin layout-sensitive text (tab labels, 11sp eyebrows, hub labels, badges, “LIVE”) via CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)) so it never overflows poster art.
  • TalkBack: label hub chips “{network} hub, opens {network} shows”; the blue CTA “Play {title}”; live cards “Live now”; group hero eyebrow+title+meta into one Modifier.semantics(mergeDescendants = true) node; mark the pulsing live dot Modifier.clearAndSetSemantics {} (decorative — the “LIVE” text carries meaning).
  • Touch targets: Material guidance is 48.dp. Hub chips (≥70×40.dp) and poster cards approach/exceed it; give the 22.dp tab icons a 48.dp hit area (NavigationBarItem handles this); the Play button is ≥ 44.dp tall.
  • Contrast: white #FFFFFF and #AEB4D6 on #0A0E2D pass WCAG AA; white on #0064FF passes AA for the CTA; sky-blue #4D9DFF on navy passes AA for the active tab. Never place body text on the blue accent or over un-scrimmed art — always keep the bottom hero scrim.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the hub Crossfade and hero Crossfade with instant swaps and stop the live-dot pulse (show a static dot); keep the press scale subtle or disable.
  • Elevation cues: shadows are nearly invisible on the navy canvas — use the subtle #0E1438 hero lift and a 1.dp Divider border on ModalBottomSheet / context menus as the floating cue, mirroring the iOS behavior.
All mobile designs →

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