Venmo (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Venmo’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables (the social feed row, the Pay/Request split pill, the balance card, the amount-entry screen, the animated-checkmark confirmation), 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 (the public emoji transaction feed, the saturated friendly Venmo Blue, the Pay/Request split pill pinned above the tab bar, the Display-cut balance numerals, the stroke-drawing checkmark) while making everything idiomatic Android — a
NavigationBarwith an overlaid pill instead of a UITabBar, aPathMeasure-driven checkmark instead of a SwiftUIShape.trim,LocalHapticFeedbackinstead of.sensoryFeedback,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for feed avatars. No color extraction, so androidx.palette is not required.
1. Color Tokens
// ui/theme/VenmoColors.kt
import androidx.compose.ui.graphics.Color
object VenmoColors {
// Canvas (Light)
val Canvas = Color(0xFFFFFFFF)
val SurfaceMuted = Color(0xFFF7F7F7)
val Divider = Color(0xFFF2F2F2)
val DividerStrong = Color(0xFFE0E0E0)
// Text
val TextPrimary = Color(0xFF2F3033) // intentional: warm charcoal, never pure black
val TextSecondary = Color(0xFF6B6E76)
val TextTertiary = Color(0xFF9AA0A8)
// Brand
val Blue = Color(0xFF008CFF) // saturated, friendly — NOT banking navy
val BlueDeep = Color(0xFF0078DE)
val BlueLight = Color(0xFF3D95CE)
val BlueTint = Color(0xFFE6F4FF)
// Semantic
val ReceivedGreen = Color(0xFF4BB543)
val ChargeRed = Color(0xFFD32E2E)
val PendingOrange = Color(0xFFF5A623)
// Dark
val DarkCanvas = Color(0xFF1A1A1A)
val DarkSurface1 = Color(0xFF242424)
val DarkSurface2 = Color(0xFF2E2E2E)
val DarkDivider = Color(0xFF2A2A2A)
val DarkText = Color(0xFFFFFFFF)
val DarkTextSec = Color(0xFFA0A0A0)
}
// Blue gradient — balance card + payment-sent hero ONLY (never regular CTAs)
import androidx.compose.ui.graphics.Brush
val VenmoBrandGradient = Brush.verticalGradient(
colors = listOf(VenmoColors.Blue, VenmoColors.BlueDeep),
)
Venmo is light-first (white canvas, social feed). Wire a Material 3 lightColorScheme so ripples, dividers, and stock component defaults inherit the brand; provide the dark scheme (Venmo Blue unchanged on dark).
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val VenmoLight = lightColorScheme(
primary = VenmoColors.Blue,
onPrimary = Color.White,
background = VenmoColors.Canvas,
onBackground = VenmoColors.TextPrimary,
surface = VenmoColors.SurfaceMuted,
onSurface = VenmoColors.TextPrimary,
surfaceVariant = VenmoColors.BlueTint,
outline = VenmoColors.Divider,
error = VenmoColors.ChargeRed,
)
private val VenmoDark = darkColorScheme(
primary = VenmoColors.Blue, // unchanged on dark
onPrimary = Color.White,
background = VenmoColors.DarkCanvas,
onBackground = VenmoColors.DarkText,
surface = VenmoColors.DarkSurface1,
onSurface = VenmoColors.DarkText,
surfaceVariant = VenmoColors.DarkSurface2,
outline = VenmoColors.DarkDivider,
error = VenmoColors.ChargeRed,
)
@Composable
fun VenmoTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (darkTheme) VenmoDark else VenmoLight,
typography = VenmoTypography,
content = content,
)
2. Typography
Venmo Sans and Venmo Display are proprietary. Drop the TTFs in res/font/. Fall back to the system font (Roboto) for Sans; the Display cut (big balance/confirm numerals) falls back to a bold weight — Source Sans Pro / Brown Std are the closest near-free substitutes if bundled.
// ui/theme/VenmoType.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 VenmoSans = FontFamily(
Font(R.font.venmo_sans_regular, FontWeight.Normal), // 400
Font(R.font.venmo_sans_semibold, FontWeight.SemiBold), // 600
Font(R.font.venmo_sans_bold, FontWeight.Bold), // 700
)
val VenmoDisplay = FontFamily(
Font(R.font.venmo_display_bold, FontWeight.Bold), // 700 — numbers + hero moments only
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object VenmoText {
val BalanceDisplay = TextStyle(VenmoDisplay, fontWeight = FontWeight.Bold, fontSize = 56.sp, lineHeight = 56.sp, letterSpacing = (-0.8).sp)
val ConfirmDisplay = TextStyle(VenmoDisplay, fontWeight = FontWeight.Bold, fontSize = 44.sp, lineHeight = 44.sp, letterSpacing = (-0.5).sp)
val SignupDisplay = TextStyle(VenmoDisplay, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 40.sp, letterSpacing = (-0.4).sp)
val AmountEntry = TextStyle(VenmoDisplay, fontWeight = FontWeight.Bold, fontSize = 72.sp, lineHeight = 72.sp, letterSpacing = (-1.0).sp)
val ScreenTitle = TextStyle(VenmoSans, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val FeedName = TextStyle(VenmoSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 21.sp)
val FeedBody = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val FeedMemo = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 21.sp)
val Timestamp = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Comment = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Amount = TextStyle(VenmoSans, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 19.sp, letterSpacing = (-0.1).sp)
val Button = TextStyle(VenmoSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val ButtonSmall = TextStyle(VenmoSans, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 15.sp)
val Input = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 21.sp)
val Tab = TextStyle(VenmoSans, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.1.sp)
val Meta = TextStyle(VenmoSans, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val VenmoTypography = Typography(
headlineLarge = VenmoText.SignupDisplay,
headlineSmall = VenmoText.ScreenTitle,
titleMedium = VenmoText.FeedName,
bodyMedium = VenmoText.FeedBody,
labelLarge = VenmoText.Button,
labelSmall = VenmoText.Tab,
)
3. Signature Components
Feed Transaction Row (THE Signature Component)
The defining component: an Instagram-style row with mixed-weight inline text (actor bold, verb regular, recipient bold), an emoji-rich memo, and a like/comment row. No amount is shown (privacy model). Compose mixes weights inline via buildAnnotatedString + SpanStyle.
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.automirrored.outlined.Comment
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.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun VenmoFeedRow(
actorName: String,
verb: String, // "paid" or "charged"
recipientName: String,
timestamp: String,
memo: String,
avatarUrl: String?,
likeCount: Int,
commentCount: Int,
modifier: Modifier = Modifier,
) {
var liked by remember { mutableStateOf(false) }
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
.background(VenmoColors.Canvas)
.drawBehind {
drawLine(VenmoColors.Divider, Offset(0f, size.height), Offset(size.width, size.height), 1.dp.toPx())
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) {
AsyncImage(
model = avatarUrl, contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape).background(VenmoColors.SurfaceMuted),
contentScale = ContentScale.Crop,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Mixed-weight transaction line — actor bold, verb regular, recipient bold, timestamp gray
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(actorName) }
append(" $verb ")
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append(recipientName) }
withStyle(SpanStyle(color = VenmoColors.TextSecondary)) { append(" · $timestamp") }
},
style = VenmoText.FeedBody,
color = VenmoColors.TextPrimary,
)
Text(memo, style = VenmoText.FeedMemo, color = VenmoColors.TextPrimary, maxLines = 3, overflow = TextOverflow.Ellipsis)
}
}
// Like / comment row, aligned under the avatar column
Row(
Modifier.padding(start = 52.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
Modifier.clickable {
liked = !liked
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS light impact
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (liked) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
contentDescription = if (liked) "Unlike" else "Like",
tint = if (liked) VenmoColors.Blue else VenmoColors.TextSecondary,
modifier = Modifier.size(18.dp),
)
Text("${likeCount + if (liked) 1 else 0}", style = VenmoText.Comment, color = VenmoColors.TextSecondary)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Outlined.Comment, "Comment", tint = VenmoColors.TextSecondary, modifier = Modifier.size(18.dp))
Text("$commentCount", style = VenmoText.Comment, color = VenmoColors.TextSecondary)
}
}
}
}
Pay / Request Split Pill (The Anchor CTA)
Blue pill, 50.dp tall, two equal halves divided by a 1.dp white line, branded blue shadow. Pinned above the tab bar.
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.vector.ImageVector
@Composable
fun VenmoPayRequestPill(
onPay: () -> Unit,
onRequest: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(50.dp)
.shadow(12.dp, CircleShape, spotColor = VenmoColors.Blue, ambientColor = VenmoColors.Blue) // branded blue glow
.clip(CircleShape)
.background(VenmoColors.Blue),
verticalAlignment = Alignment.CenterVertically,
) {
SplitHalf("Pay", Icons.Filled.ArrowUpward, Modifier.weight(1f), onPay)
Box(Modifier.width(1.dp).height(30.dp).background(Color.White.copy(alpha = 0.6f)))
SplitHalf("Request", Icons.Filled.ArrowDownward, Modifier.weight(1f), onRequest)
}
}
@Composable
private fun SplitHalf(title: String, icon: ImageVector, modifier: Modifier, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxHeight()
.clip(CircleShape)
.background(if (pressed) VenmoColors.BlueDeep else Color.Transparent)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS medium impact
onClick()
},
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(title, style = VenmoText.Button, color = Color.White)
}
}
Balance Card (Me Tab Hero)
Gradient card, big Display balance with tabular digits, white action pills. Gradient is reserved for this + the confirmation hero.
@Composable
fun VenmoBalanceCard(
balance: String, // pre-formatted "$124.50"
onTransfer: () -> Unit,
onAddMoney: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(180.dp)
.clip(RoundedCornerShape(20.dp))
.background(VenmoBrandGradient)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("Venmo balance", style = VenmoText.ButtonSmall, color = Color.White.copy(alpha = 0.8f))
Text(
balance,
style = VenmoText.BalanceDisplay.copy(
fontFeatureSettings = "tnum" // tabular digits so columns align
),
color = Color.White,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
WhitePill("Transfer to bank", onTransfer)
WhitePill("Add money", onAddMoney)
}
}
}
@Composable
private fun WhitePill(title: String, onClick: () -> Unit) {
Box(
Modifier.clip(CircleShape).background(Color.White).clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp)
) { Text(title, style = VenmoText.ButtonSmall, color = VenmoColors.Blue) }
}
Amount Input Screen (Pay Flow)
Giant centered Display amount, flat memo input, horizontal emoji picker (system emoji — the brand depends on it), fixed blue Pay pill.
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
@Composable
fun VenmoAmountEntry(
displayAmount: String, // pre-formatted "$24.50"
onPay: () -> Unit,
modifier: Modifier = Modifier,
) {
var memo by remember { mutableStateOf(TextFieldValue("")) }
val quickEmojis = listOf("🍕", "🍺", "🚗", "💰", "🎉", "☕", "🏠", "🎁", "🛒", "✈️", "🎬", "💵")
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Spacer(Modifier.weight(1f))
Text(
displayAmount,
style = VenmoText.AmountEntry.copy(fontFeatureSettings = "tnum"), // dollar sign glued, tabular
color = VenmoColors.TextPrimary,
)
BasicTextField(
value = memo,
onValueChange = { memo = it },
textStyle = VenmoText.Input.copy(color = VenmoColors.TextPrimary, textAlign = TextAlign.Center),
cursorBrush = SolidColor(VenmoColors.Blue),
modifier = Modifier.fillMaxWidth().padding(horizontal = 40.dp),
decorationBox = { inner ->
if (memo.text.isEmpty()) {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("What's it for?", style = VenmoText.Input, color = VenmoColors.TextTertiary)
}
}
inner()
},
)
Row(
Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
quickEmojis.forEach { e ->
Box(
Modifier
.size(40.dp, 32.dp)
.clip(CircleShape)
.background(VenmoColors.SurfaceMuted)
.clickable {
memo = memo.copy(text = memo.text + e)
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // ~iOS selection
},
contentAlignment = Alignment.Center,
) { Text(e, style = VenmoText.Input) } // system emoji font — never re-render with custom art
}
}
Spacer(Modifier.weight(1f))
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 24.dp)
.height(52.dp)
.clip(CircleShape)
.background(VenmoColors.Blue)
.clickable(onClick = onPay),
contentAlignment = Alignment.Center,
) { Text("Pay $displayAmount", style = VenmoText.Button, color = Color.White) }
}
}
Payment-Sent Confirmation (Animated Checkmark)
Full-screen gradient, a stroke-drawing checkmark inside a blue circle, confetti burst, success haptic. Compose draws the check progressively via PathMeasure + an Animatable 0→1.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
@Composable
fun VenmoSentConfirmation(
amount: String, // "$24.50"
recipientName: String,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val circleScale = remember { Animatable(0f) }
val checkProgress = remember { Animatable(0f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) {
circleScale.animateTo(1f, spring(dampingRatio = 0.7f, stiffness = 400f)) // 0 → 1.0 spring
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS success
checkProgress.animateTo(1f, tween(350)) // stroke draws in
}
Box(
modifier = modifier.fillMaxSize().background(VenmoBrandGradient),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp)) {
Box(contentAlignment = Alignment.Center) {
Box(Modifier.size(160.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.15f)))
Box(
Modifier.size(80.dp).graphicsLayer { scaleX = circleScale.value; scaleY = circleScale.value }
.clip(CircleShape).background(VenmoColors.Blue)
)
AnimatedCheck(progress = checkProgress.value, size = 44.dp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Sent", style = VenmoText.SignupDisplay, color = Color.White)
Text(amount, style = VenmoText.ConfirmDisplay.copy(fontFeatureSettings = "tnum"), color = Color.White)
Text("To $recipientName", style = VenmoText.Input, color = Color.White.copy(alpha = 0.8f))
}
WhitePill("Done", onDone)
}
}
}
@Composable
private fun AnimatedCheck(progress: Float, size: Dp) {
Canvas(Modifier.size(size)) {
val w = this.size.width
val h = this.size.height
val full = Path().apply {
moveTo(0f, h * 0.5f)
lineTo(w * 0.35f, h)
lineTo(w, 0f)
}
val measure = PathMeasure().apply { setPath(full, false) }
val drawn = Path()
measure.getSegment(0f, measure.length * progress, drawn, true)
drawPath(
drawn,
color = Color.White,
style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round),
)
}
}
The confetti burst (8–12 white/blue particles, ~800ms) is an extra
Canvaslayer driven by anAnimatable0→1 mapping particle radius/alpha over time — keep it skippable for reduced-motion.
4. Tab Bar with Overlaid Pay/Request Pill (the distinctive interaction)
Venmo’s defining structural move: a 5-slot bottom nav where the center is the Pay/Request split pill overlapping the bar, not a tab. Compose stacks the NavigationBar and the pill in a Box.
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun VenmoRootScaffold(
selected: Int,
onSelect: (Int) -> Unit,
onPay: () -> Unit,
onRequest: () -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
bottomBar = {
Box {
NavigationBar(
containerColor = VenmoColors.Canvas.copy(alpha = 0.96f), // Android has no live blur
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Incomplete" to Icons.Filled.Schedule,
null to null, // spacer slot for the pill
"Cards" to Icons.Filled.CreditCard,
"Me" to Icons.Filled.Person,
)
items.forEachIndexed { i, (label, icon) ->
if (label == null || icon == null) {
NavigationBarItem(selected = false, onClick = {}, enabled = false, icon = {})
} else {
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = VenmoText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = VenmoColors.Blue,
selectedTextColor = VenmoColors.Blue,
unselectedIconColor = VenmoColors.TextSecondary,
unselectedTextColor = VenmoColors.TextSecondary,
indicatorColor = Color.Transparent,
),
)
}
}
}
VenmoPayRequestPill(
onPay = onPay, onRequest = onRequest,
modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(0.62f).padding(top = 6.dp),
)
}
},
content = content,
)
}
5. Navigation
Venmo’s nav is the bottom tab bar above (Home feed | Incomplete | [Pay/Request pill] | Cards | Me), labels always shown, active #008CFF. Screen headers use a left-aligned title (Activity, Me) in VenmoText.ScreenTitle with a 0.5.dp #F2F2F2 bottom divider on scroll. There is no live blur on Android — use the 96%-opaque canvas surface shown above. The QR scanner is a full-screen Surface(color = Color.Black) with blue corner brackets.
6. Motion
| Moment | Compose recipe |
|---|---|
| Pay/Request half press | collectIsPressedAsState → half background → #0078DE; HapticFeedbackType.LongPress |
| Amount typing | per-character animateFloatAsState 1.0 → 1.05 → 1.0 scale bump (~50ms) |
| Payment-sent checkmark | Animatable circle scale spring(0.7) 0→1, then PathMeasure segment via Animatable tween(350) 0→1; confetti Canvas 800ms; success haptic |
| Feed row like | heart Animatable keyframes 1.0 → 1.2 → 1.0 over 300ms, tint → blue, HapticFeedbackType.TextHandleMove |
| Balance reveal | animate a Float 0 → balance over 400ms tween, format per frame (tabular digits) |
| Pull to refresh | Material 3 PullToRefreshBox tinted Venmo Blue |
// Feed-row heart bounce (DESIGN.md §6) — 1.0 → 1.2 → 1.0
@Composable
fun rememberLikeBounce(liked: Boolean): Float {
val scale = remember { Animatable(1f) }
LaunchedEffect(liked) {
if (liked) {
scale.animateTo(1.2f, spring(dampingRatio = 0.6f, stiffness = 600f))
scale.animateTo(1.0f, tween(120))
}
}
return scale.value
}
Haptics: prefer LocalHapticFeedback. For the payment-sent success use a Vibrator VibrationEffect.createOneShot(20, ...); emoji-chip taps map to LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK). Motion stays calm overall (spring dampings 0.6–0.85) — Venmo is not an aggressive-animation app.
7. Icons
Venmo uses mostly standard glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The checkmark is a custom Canvas path (above), and the QR viewfinder brackets are custom-drawn — neither has a clean SF Symbol equivalent.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Pay arrow | arrow.up |
Icons.Filled.ArrowUpward |
| Request arrow | arrow.down |
Icons.Filled.ArrowDownward |
| Like | heart / heart.fill |
Icons.Filled.FavoriteBorder / Icons.Filled.Favorite |
| Comment | bubble.left |
Icons.AutoMirrored.Outlined.Comment |
| Share | square.and.arrow.up |
Icons.Filled.Share |
| Send (comment) | arrow.up.circle.fill |
Icons.AutoMirrored.Filled.Send |
| Search | magnifyingglass |
Icons.Filled.Search |
| QR scan | qrcode.viewfinder |
Icons.Filled.QrCodeScanner |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Incomplete (tab) | clock.fill |
Icons.Filled.Schedule |
| Cards (tab) | creditcard.fill |
Icons.Filled.CreditCard |
| Me (tab) | person.fill |
Icons.Filled.Person |
| Checkmark animation | custom Shape | custom Canvas + PathMeasure |
| Verified badge | checkmark.seal.fill |
Icons.Filled.Verified |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; the branded blue pill shadow needs API 28+ for
spotColor— fall back to a soft blue radial glow drawn behind the pill on older devices).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(). The Pay/Request pill + tab bar extend to the home indicator; pad the pill ~24.dp aboveWindowInsets.navigationBarsso it clears the gesture nav. - Font scaling:
sphonors the user’s font scale automatically — keep it on feed body, memo, comments. Pin layout-sensitive Display text: the 72sp amount-entry numeral is layout-critical (fixed); the 56sp balance can scale but caps at 72sp then ellipsizes; tab labels are fixed at 11sp. Pin via deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: merge the feed row into one element with
Modifier.semantics(mergeDescendants = true) { contentDescription = "$actorName $verb $recipientName, $timestamp. Memo: $memo. $likeCount likes, $commentCount comments" }; the like/comment buttons are separate focusable children with their own labels. The balance card reads “Venmo balance, $124.50”. Announce “Payment sent successfully” after the checkmark animation completes. - Touch targets: Material guidance is 48.dp minimum. Both halves of the 50.dp pill clear it; the 18.dp like/comment glyphs need a 32–48.dp hit area via padding; the 40.dp avatar carries a 44.dp+ hit area; emoji chips are 32–40.dp and should expand to 44.dp vertically.
- Contrast:
#6B6E76secondary on#FFFFFFpasses WCAG AA at 13sp+.#9AA0A8tertiary is decorative/placeholder only — never use it for content text. White on Venmo Blue#008CFFpasses AA at 16sp Bold (the pill/buttons). On the blue gradient card, the 80%-opacity white “Venmo balance” label is a known low-contrast pair against the lighter#008CFFstop — keep that label ≥14sp. - Reduce motion: when a reduced-motion preference is set, skip the confetti burst and the checkmark stroke-draw (show the filled check immediately), and replace the balance tick-up with the final value.
- Emoji rendering: rely on the system emoji font — never re-render memo emojis with custom art. Venmo’s playful emoji-memo culture (🍕🍺 Pizza Friday) depends on native OS emoji; substituting an icon set breaks the brand signal.
- Dynamic color: do not enable Material You
dynamicColorScheme— Venmo’s identity is the single saturated#008CFFbrand blue (pill, links, gradient). Letting it shift toward the user’s wallpaper would dull the “friendly, not banking-navy” signature and desaturate the gradient hero.