Vercel (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Vercel’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the deployments list + build-log viewer, 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 (Vercel’s true-black canvas, the deployments list, the status traffic-light, Geist Sans/Mono, hairline borders instead of shadows) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ModalBottomSheetinstead of a SwiftUI sheet,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars. No color extraction — Vercel’s palette is fixed and monochrome, so Palette is not needed. Vercel is dark-mode-native; a light scheme is provided as a clean inversion.
1. Color Tokens
// ui/theme/VercelColors.kt
import androidx.compose.ui.graphics.Color
object VercelColors {
// Canvas & Surfaces (Dark — default & brand)
val Canvas = Color(0xFF000000) // TRUE black — NOT #191919
val Surface1 = Color(0xFF0A0A0A)
val Surface2 = Color(0xFF111111)
val Surface3 = Color(0xFF1A1A1A)
val Divider = Color(0xFF2E2E2E)
val Border = Color(0xFF333333)
// Canvas & Surfaces (Light)
val CanvasLight = Color(0xFFFFFFFF)
val SurfaceLight = Color(0xFFFAFAFA)
val SurfaceLight2 = Color(0xFFF2F2F2)
val DividerLight = Color(0xFFEAEAEA)
val BorderLight = Color(0xFFE1E1E1)
// Text
val TextPrimary = Color(0xFFEDEDED)
val TextSecondary = Color(0xFFA1A1A1)
val TextTertiary = Color(0xFF707070)
val TextOnLight = Color(0xFF000000)
// Interactive
val WhiteFill = Color(0xFFEDEDED) // primary button fill (dark)
val WhitePress = Color(0xFFCFCFCF)
val Blue = Color(0xFF0070F3)
val BlueHover = Color(0xFF3291FF)
// Status (deployment state — the systemic accent)
val Ready = Color(0xFF0CCE6B)
val Building = Color(0xFFF5A623)
val ErrorRed = Color(0xFFE5484D)
val ReadyDim = Color(0xFF0F3D2E)
val ErrorDim = Color(0xFF3A1416)
// Highlight (decorative only — avatar gradient / rare marketing)
val HlPurple = Color(0xFF7928CA)
val HlCyan = Color(0xFF50E3C2)
val HlPink = Color(0xFFFF0080)
}
Wire it into both schemes. Vercel is dark-first (true black); the light scheme is a clean inversion.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val VercelDark = darkColorScheme(
primary = VercelColors.WhiteFill, // white fill — no brand accent
onPrimary = VercelColors.Canvas,
background = VercelColors.Canvas, // TRUE black
onBackground = VercelColors.TextPrimary,
surface = VercelColors.Surface1,
onSurface = VercelColors.TextPrimary,
surfaceVariant = VercelColors.Surface2,
outline = VercelColors.Divider,
error = VercelColors.ErrorRed,
)
private val VercelLight = lightColorScheme(
primary = VercelColors.TextOnLight,
onPrimary = VercelColors.CanvasLight,
background = VercelColors.CanvasLight,
onBackground = VercelColors.TextOnLight,
surface = VercelColors.SurfaceLight,
onSurface = VercelColors.TextOnLight,
surfaceVariant = VercelColors.SurfaceLight2,
outline = VercelColors.DividerLight,
error = VercelColors.ErrorRed,
)
@Composable
fun VercelTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) VercelDark else VercelLight,
typography = VercelTypography,
content = content,
)
2. Typography
Vercel’s type system is Geist Sans (UI prose) + Geist Mono (every URL/branch/commit/env var/log). Both SIL OFL — drop the TTFs in res/font/. The split is by content type, never a user setting. Tight negative tracking on headings.
// ui/theme/VercelType.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 Geist = FontFamily(
Font(R.font.geist_regular, FontWeight.Normal),
Font(R.font.geist_medium, FontWeight.Medium),
Font(R.font.geist_semibold, FontWeight.SemiBold),
Font(R.font.geist_bold, FontWeight.Bold),
Font(R.font.geist_extrabold, FontWeight.ExtraBold),
)
val GeistMono = FontFamily(
Font(R.font.geist_mono_regular, FontWeight.Normal),
Font(R.font.geist_mono_medium, FontWeight.Medium),
)
// Named ramp — mirrors DESIGN.md §3 (pt → sp 1:1)
object VercelText {
val Display = TextStyle(Geist, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.6).sp)
val Title = TextStyle(Geist, fontWeight = FontWeight.Bold, fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Geist, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Subsection = TextStyle(Geist, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(Geist, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val Label = TextStyle(Geist, fontWeight = FontWeight.Medium, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val BodyStrong = TextStyle(Geist, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
val Meta = TextStyle(Geist, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val Caption = TextStyle(Geist, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(Geist, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Geist, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 14.sp, letterSpacing = (-0.1).sp)
// Mono
val MonoUrl = TextStyle(GeistMono, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val MonoLog = TextStyle(GeistMono, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 18.sp)
val MonoTag = TextStyle(GeistMono, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp)
val MonoBadge = TextStyle(GeistMono, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val VercelTypography = Typography(
headlineLarge = VercelText.Display,
headlineMedium = VercelText.Title,
titleMedium = VercelText.Subsection,
bodyMedium = VercelText.Body,
labelSmall = VercelText.Tab,
)
3. Signature Components
Deployment Card (the spine of the app)
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CallSplit
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
enum class DeployState(val color: Color, val label: String) {
Ready(VercelColors.Ready, "Ready"),
Building(VercelColors.Building, "Building"),
Error(VercelColors.ErrorRed, "Error"),
}
@Composable
fun DeploymentCard(
state: DeployState,
env: String, // "Production" / "Preview"
url: String,
branch: String,
relativeTime: String,
modifier: Modifier = Modifier,
) {
Column(
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(VercelColors.Surface1)
.border(1.dp, VercelColors.Divider, RoundedCornerShape(12.dp)) // border, NOT shadow
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// 9dp dot + 3dp halo at 15% of the status color
Box(
Modifier.size(15.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(state.color.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) { Box(Modifier.size(9.dp).clip(androidx.compose.foundation.shape.CircleShape).background(state.color)) }
Text(
state.label,
style = VercelText.BodyStrong,
color = if (state == DeployState.Ready) VercelColors.TextPrimary else state.color,
)
Spacer(Modifier.weight(1f))
EnvTag(env)
}
Text(url, style = VercelText.MonoUrl, color = VercelColors.TextPrimary, maxLines = 1)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Outlined.CallSplit, null, tint = VercelColors.TextTertiary, modifier = Modifier.size(12.dp))
Text(branch, style = VercelText.MonoUrl, color = VercelColors.TextSecondary)
Spacer(Modifier.weight(1f))
Text(relativeTime, style = VercelText.Meta, color = VercelColors.TextTertiary)
}
}
}
@Composable
fun EnvTag(label: String) {
Text(
label,
style = VercelText.MonoTag,
color = VercelColors.TextSecondary,
modifier = Modifier
.clip(RoundedCornerShape(5.dp))
.background(VercelColors.Surface3)
.border(1.dp, VercelColors.Border, RoundedCornerShape(5.dp))
.padding(horizontal = 7.dp, vertical = 3.dp),
)
}
Status Badge (pill)
import androidx.compose.foundation.shape.CircleShape
@Composable
fun StatusBadge(state: DeployState) {
Row(
Modifier
.clip(CircleShape)
.background(state.color.copy(alpha = 0.08f))
.border(1.dp, state.color.copy(alpha = 0.4f), CircleShape)
.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(Modifier.size(7.dp).clip(CircleShape).background(state.color))
Text(state.label, style = VercelText.MonoBadge, color = state.color)
}
}
Primary / Secondary Buttons
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.remember
@Composable
fun VercelPrimaryButton(title: String, onClick: () -> Unit) {
val src = remember { MutableInteractionSource() }
val pressed by src.collectIsPressedAsState()
Button(
onClick = onClick,
interactionSource = src,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (pressed) VercelColors.WhitePress else VercelColors.WhiteFill,
contentColor = Color.Black,
),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 11.dp),
) { Text(title, style = VercelText.Button) }
}
@Composable
fun VercelSecondaryButton(title: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(8.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, VercelColors.Border),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 10.dp),
) { Text(title, style = VercelText.Button.copy(fontWeight = FontWeight.Medium), color = VercelColors.TextPrimary) }
}
Build-Log Viewer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.LaunchedEffect
data class LogLine(val n: Int, val text: String, val kind: Kind) {
enum class Kind { Normal, Error, Success, Dim }
}
@Composable
fun BuildLogViewer(lines: List<LogLine>) {
val listState = rememberLazyListState()
LaunchedEffect(lines.size) { if (lines.isNotEmpty()) listState.animateScrollToItem(lines.lastIndex) }
fun color(k: LogLine.Kind) = when (k) {
LogLine.Kind.Normal -> VercelColors.TextPrimary
LogLine.Kind.Error -> VercelColors.ErrorRed
LogLine.Kind.Success -> VercelColors.Ready
LogLine.Kind.Dim -> VercelColors.TextTertiary
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().background(VercelColors.Canvas),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
) {
items(lines, key = { it.n }) { l ->
Row(Modifier.padding(vertical = 1.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("${l.n}", style = VercelText.MonoLog, color = VercelColors.TextTertiary, modifier = Modifier.width(32.dp))
Text(l.text, style = VercelText.MonoLog, color = color(l.kind))
}
}
}
}
Triangle Logomark
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
@Composable
fun VercelTriangle(size: androidx.compose.ui.unit.Dp = 22.dp, fill: Color = VercelColors.TextPrimary) {
Canvas(Modifier.size(size, size * 0.855f)) {
val p = Path().apply {
moveTo(this@Canvas.size.width / 2f, 0f)
lineTo(this@Canvas.size.width, this@Canvas.size.height)
lineTo(0f, this@Canvas.size.height)
close()
}
drawPath(p, fill)
}
}
4. Navigation
Vercel has minimal chrome: a compact top bar and a 4-tab bottom strip. On Android, model the strip as a NavigationBar (no tint pill — active is pure white). The project switcher is a ModalBottomSheet.
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun VercelBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = VercelColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Overview" to Icons.Filled.Home,
"Deployments" to Icons.Filled.List,
"Analytics" to Icons.Filled.ShowChart,
"Settings" to Icons.Filled.Settings,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, label, Modifier.size(21.dp)) },
label = { Text(label, style = VercelText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color.White, // pure white, no accent
selectedTextColor = Color.White,
unselectedIconColor = VercelColors.TextTertiary,
unselectedTextColor = VercelColors.TextTertiary,
indicatorColor = Color.Transparent, // NO Material pill — Vercel has none
),
)
}
}
}
The project switcher (ModalBottomSheet, Surface1 container, 16.dp top radius) lists projects: name (Label) + production domain (MonoUrl TextSecondary) + trailing chevron, with a pinned search field. sheetState gives drag-dismiss for free.
5. Motion
Vercel motion is minimal and immediate. Borders + surface lightness signal elevation; there are essentially no shadows.
| Moment | Compose recipe |
|---|---|
| Status transition (Building → Ready) | animateColorAsState(targetValue = state.color, tween(240)); soft success haptic |
| Building pulse | halo scale via rememberInfiniteTransition tween(1200, easing = LinearEasing) reverse |
| Button press | collectIsPressedAsState → swap container color + scale 0.98 tween(120) |
| Sheet present | ModalBottomSheet default slide-up; scrim scrimColor = Black.copy(alpha = 0.7f) |
| Tab switch | instant content swap — NO slide (Vercel favors immediacy) |
| Pull-to-refresh | PullToRefreshBox; indicator tint VercelColors.Blue |
| Log auto-scroll | LazyListState.animateScrollToItem(lastIndex) on append |
// Status cross-fade — the canonical Vercel motion
val dotColor by animateColorAsState(targetValue = state.color, animationSpec = tween(240), label = "deployStatus")
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft success on a Ready transition and on destructive confirm. Auto-refresh is silent; only show a snackbar on error.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. The Vercel triangle is a custom Canvas path, not an icon glyph.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Overview (tab) | house |
Icons.Filled.Home |
| Deployments (tab) | list.bullet |
Icons.Filled.List |
| Analytics (tab) | chart.line.uptrend.xyaxis |
Icons.Filled.ShowChart |
| Settings (tab) | gearshape |
Icons.Filled.Settings |
| Git branch | arrow.triangle.branch |
Icons.Outlined.CallSplit |
| Commit | circle.dotted |
Icons.Filled.Commit |
| Domain | globe |
Icons.Filled.Language |
| Redeploy | arrow.clockwise |
Icons.Filled.Refresh |
| Visit / open | arrow.up.right |
Icons.Filled.OpenInNew |
| Logs | terminal |
Icons.Filled.Terminal |
| Copy | doc.on.doc |
Icons.Filled.ContentCopy |
| Search | magnifyingglass |
Icons.Filled.Search |
| Reveal env value | eye / eye.slash |
Icons.Filled.Visibility / VisibilityOff |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Success | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| Error | exclamationmark.triangle.fill |
Icons.Filled.Warning |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
ModalBottomSheet+ modern motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the true-black canvas wants light-content system bars. The compact top bar respects the camera cutout; the build-log and forms must respect the IME — useModifier.imePadding(). - True black: the dark
backgroundis#000000, NOT#191919. Do not enable Material YoudynamicColorScheme()— Vercel’s monochrome identity must hold regardless of wallpaper (there is no brand accent to harmonize with one). - No shadows: elevation is a lighter surface (
Surface1) + a 1dpDividerborder. SettonalElevation = 0.dpeverywhere; never addshadow()to cards (invisible on black, off-brand). - Font scaling:
sphonors the user’s font scale — keep it on Display/Title/Section/Body/Meta and the build-log Mono (stays monospace as it scales). Pin layout-sensitive text (10sp tab labels, env tags, mono badges, ≤12sp captions) viaCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: compose the deployment card into one announcement — “Deployment: {state}, {env}, {branch}, {relativeTime}” via
Modifier.semantics(mergeDescendants = true) { contentDescription = ... }; the status dot is decorative. - Color is not the only signal: every status dot is paired with its text label (“Ready”/“Building”/“Error”), so color-blind users never rely on hue.
- Touch targets: Material guidance is 48.dp. The deployment card is full-row tappable (≥64.dp); give icon buttons a 48.dp hit area; primary buttons ≥ 40.dp tall.
- Contrast:
#EDEDEDon#000000≈ 18:1 (AAA).#A1A1A1on#000000passes AA for secondary. Status-on-dim badge pairs (#0CCE6Bonrgba(12,206,107,0.08)) are validated. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the Building-pulse loop and snap the status color (no cross-fade); keep the success haptic and the selection highlight. - Logs performance:
LazyColumn(never a plainColumn) for build logs — they can be thousands of lines;animateScrollToItem(lastIndex)to follow the tail. - Dark mode: dark IS the brand default. The light scheme is a clean inversion —
#FFFFFFcanvas,#000000text,#FAFAFAcards,#EAEAEAborders; status colors stay identical.