Monzo (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Monzo’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the Hot Coral card hero + transaction feed + Pots, 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 Hot Coral card hero, Monzo Navy, the emoji transaction feed, round Pot coins) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,LazyColumnfor the feed,LazyRowfor Pots,graphicsLayerfor the card flip,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for merchant logos. Do not enable Material You dynamicColorScheme — Hot Coral is the brand and must hold regardless of wallpaper. Monzo is dark-and-navy friendly; a full dark scheme is provided.
1. Color Tokens
// ui/theme/MonzoColors.kt
import androidx.compose.ui.graphics.Color
object MonzoColors {
// Brand
val Coral = Color(0xFFFF3464)
val CoralPressed = Color(0xFFE02855)
val Navy = Color(0xFF14233C)
val NavyDeep = Color(0xFF0E1620)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFF6F7F9)
val Surface = Color(0xFFFFFFFF)
val SurfacePressed = Color(0xFFEEF0F3)
val Divider = Color(0xFFE4E7EB)
// Canvas & Surfaces (Dark)
val DarkCanvas = Color(0xFF0E1620) // deep navy — NOT pure black
val DarkSurface1 = Color(0xFF14233C) // the brand navy
val DarkSurface2 = Color(0xFF1B2F4D)
val DarkDivider = Color(0xFF243A57)
// Text
val TextPrimary = Color(0xFF14233C)
val TextSecondary = Color(0xFF5C6B82)
val TextTertiary = Color(0xFF94A1B5)
val DarkTextPrimary = Color(0xFFF4F6F9)
val DarkTextSecondary = Color(0xFF9FB0C6)
val DarkTextTertiary = Color(0xFF61748E)
// Pot / category accents (light / dark)
val MintLight = Color(0xFF2FB59A); val Mint = Color(0xFF5CE0C4)
val SkyLight = Color(0xFF3E7FC4); val Sky = Color(0xFF5AA9F0)
val GoldLight = Color(0xFFE0A23A); val Gold = Color(0xFFFFC75F)
val VioletLight = Color(0xFF7A4FD0); val Violet = Color(0xFFA98AE6)
val TealLight = Color(0xFF1F9E8F); val Teal = Color(0xFF46C7B7)
// Semantic
val IncomeLight = Color(0xFF1FA971); val Income = Color(0xFF2FCB8F)
val ErrorLight = Color(0xFFE0354A); val Error = Color(0xFFFF5A6E)
val WarningLight = Color(0xFFD98A1F); val Warning = Color(0xFFFFB347)
}
Wire it into both schemes. Monzo is happiest dark-and-navy; the dark scheme uses #0E1620 deep navy, never true black.
// 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 MonzoLight = lightColorScheme(
primary = MonzoColors.Coral,
onPrimary = Color.White,
background = MonzoColors.Canvas,
onBackground = MonzoColors.TextPrimary,
surface = MonzoColors.Surface,
onSurface = MonzoColors.TextPrimary,
surfaceVariant = MonzoColors.SurfacePressed,
outline = MonzoColors.Divider,
error = MonzoColors.ErrorLight,
)
private val MonzoDark = darkColorScheme(
primary = MonzoColors.Coral,
onPrimary = Color.White,
background = MonzoColors.DarkCanvas,
onBackground = MonzoColors.DarkTextPrimary,
surface = MonzoColors.DarkSurface1,
onSurface = MonzoColors.DarkTextPrimary,
surfaceVariant = MonzoColors.DarkSurface2,
outline = MonzoColors.DarkDivider,
error = MonzoColors.Error,
)
@Composable
fun MonzoTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) MonzoDark else MonzoLight,
typography = MonzoTypography,
content = content,
)
2. Typography
Monzo’s product face is Inter (SIL OFL) — drop the TTFs in res/font/. Money values use tabular figures via a font feature setting. Body 400; titles 700; balance/screen-title 800.
// ui/theme/MonzoType.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.font.FontFeature
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 feature for money
val Tnum = androidx.compose.ui.text.font.FontFeatureSettings("tnum")
object MonzoText {
val ScreenTitle = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val Balance = TextStyle(Inter, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp, lineHeight = 33.sp, letterSpacing = (-0.5).sp, fontFeatureSettings = "tnum")
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val Subsection = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 23.sp)
val Merchant = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val Amount = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp, fontFeatureSettings = "tnum")
val Meta = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 18.sp)
val Label = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val CardNumber = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val MonzoTypography = Typography(
headlineLarge = MonzoText.ScreenTitle,
headlineMedium = MonzoText.Section,
titleMedium = MonzoText.Subsection,
bodyMedium = MonzoText.Body,
labelSmall = MonzoText.Tab,
)
3. Signature Components
Hot Coral Card Hero
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
@Composable
fun MonzoCardHero(
balance: String,
maskedNumber: String,
modifier: Modifier = Modifier,
) {
var flipped by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(if (flipped) 180f else 0f, tween(350), label = "cardFlip")
Box(
modifier
.padding(horizontal = 20.dp)
.fillMaxWidth()
.height(132.dp)
.graphicsLayer { rotationY = rotation; cameraDistance = 12f * density }
.shadow(30.dp, RoundedCornerShape(18.dp), spotColor = MonzoColors.Coral.copy(alpha = 0.4f))
.clip(RoundedCornerShape(18.dp))
.background(
Brush.linearGradient(listOf(Color(0xFFFF3464), Color(0xFFFF5A7F), Color(0xFFE02855)))
)
.clickable { flipped = !flipped }
.padding(20.dp),
) {
Text("MONZO", style = MonzoText.Label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = Color.White.copy(alpha = 0.9f), modifier = Modifier.align(Alignment.TopEnd))
Column(Modifier.align(Alignment.CenterStart)) {
Text(balance, style = MonzoText.Balance, color = Color.White)
Text("Available to spend", style = MonzoText.Label, color = Color.White.copy(alpha = 0.85f))
}
Text(maskedNumber, style = MonzoText.CardNumber, color = Color.White.copy(alpha = 0.9f),
modifier = Modifier.align(Alignment.BottomStart))
}
}
Transaction Row
@Composable
fun TransactionRow(
emoji: String,
tint: Color,
merchant: String,
category: String,
time: String,
amount: String,
isIncome: Boolean = false,
isPending: Boolean = false,
) {
Row(
Modifier
.fillMaxWidth()
.alpha(if (isPending) 0.6f else 1f)
.padding(horizontal = 20.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(42.dp).clip(CircleShape).background(tint.copy(alpha = 0.18f)),
contentAlignment = Alignment.Center,
) { Text(emoji, fontSize = 20.sp) }
Column(Modifier.weight(1f)) {
Text(merchant, style = MonzoText.Merchant, color = MonzoColors.DarkTextPrimary)
Text("$category · $time", style = MonzoText.Meta, color = MonzoColors.DarkTextSecondary)
}
Text(
amount, style = MonzoText.Amount,
color = if (isIncome) MonzoColors.Income else MonzoColors.DarkTextPrimary,
)
}
}
Pot Coin
@Composable
fun PotCoin(emoji: String, name: String, amount: String, gradient: List<Color>) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
Modifier
.size(92.dp)
.shadow(16.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.2f))
.clip(RoundedCornerShape(16.dp))
.background(Brush.linearGradient(gradient)),
contentAlignment = Alignment.Center,
) { Text(emoji, fontSize = 30.sp) }
Spacer(Modifier.height(8.dp))
Text(name, style = MonzoText.Label, color = MonzoColors.DarkTextPrimary)
Text(amount, style = MonzoText.Label.copy(fontSize = 11.sp), color = MonzoColors.DarkTextSecondary)
}
}
Pot Card (detail / list)
import androidx.compose.foundation.shape.CircleShape
@Composable
fun PotCard(emoji: String, name: String, saved: String, goal: String, progress: Float) {
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(MonzoColors.DarkSurface2)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Box(
Modifier.size(48.dp).clip(RoundedCornerShape(12.dp))
.background(Brush.linearGradient(listOf(MonzoColors.Mint, MonzoColors.MintLight))),
contentAlignment = Alignment.Center,
) { Text(emoji, fontSize = 22.sp) }
Column(Modifier.weight(1f)) {
Text(name, style = MonzoText.Merchant, color = MonzoColors.DarkTextPrimary)
Text("$saved of $goal goal", style = MonzoText.Label, color = MonzoColors.DarkTextSecondary)
Spacer(Modifier.height(8.dp))
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(MonzoColors.DarkDivider)) {
Box(Modifier.fillMaxWidth(progress).height(6.dp).clip(RoundedCornerShape(3.dp)).background(MonzoColors.Mint))
}
}
}
}
Buttons
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
@Composable
fun MonzoPrimaryButton(title: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
Button(
onClick = onClick,
modifier = modifier.fillMaxWidth().heightIn(min = 48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MonzoColors.Coral, contentColor = Color.White),
) { Text(title, style = MonzoText.Button) }
}
@Composable
fun MonzoPillButton(title: String, onClick: () -> Unit) {
OutlinedButton(
onClick = onClick,
shape = RoundedCornerShape(500.dp),
border = androidx.compose.foundation.BorderStroke(1.5.dp, MonzoColors.Coral),
) { Text(title, style = MonzoText.Label.copy(fontWeight = FontWeight.SemiBold, fontSize = 14.sp), color = MonzoColors.Coral) }
}
4. Navigation
Monzo has a 5-tab bottom strip with a Hot Coral active state and no tint pill — active is just the coral icon + label.
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
@Composable
fun MonzoBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = MonzoColors.DarkCanvas, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Payments" to Icons.Filled.CreditCard,
"Pots" to Icons.Filled.GridView,
"Trends" to Icons.Filled.TrendingUp,
"Account" to Icons.Filled.AccountCircle,
)
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 = MonzoText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MonzoColors.Coral,
selectedTextColor = MonzoColors.Coral,
unselectedIconColor = MonzoColors.DarkTextTertiary,
unselectedTextColor = MonzoColors.DarkTextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Monzo has none
),
)
}
}
}
The Home screen is a LazyColumn: card hero (item), Pots LazyRow (item), then day-grouped transaction rows with sticky day-header rows (stickyHeader). The large-title header collapses on scroll via a TopAppBar + nestedScroll.
5. Motion
Monzo motion is confident and physical — the card flips, coins spring into Pots, numbers tick.
| Moment | Compose recipe |
|---|---|
| Card flip to controls | animateFloatAsState 0 → 180° tween(350) on graphicsLayer { rotationY } with cameraDistance |
| Pot money move | drag a coin via Modifier.draggable, animateOffsetAsState spring (dampingRatio = 0.7f) into the Pot; balance via animateIntAsState ticker |
| Feed insert | new row AnimatedVisibility slideInVertically { -it } + fadeIn(tween(250)); highlight wash tween(800) fade-out |
| Pull-to-refresh | PullToRefreshBox with MonzoColors.Coral indicator |
| Tab change | instant color crossfade (NavigationBar handles); ~120ms feel |
| Page navigation | Nav3/NavHost slide push tween(300) |
| Number ticker | animateIntAsState(targetBalancePence, tween(250)) then format to currency |
// Balance ticker — the canonical Monzo "money moved" motion
val shown by animateIntAsState(balancePence, tween(250), label = "balanceTick")
Text(formatCurrency(shown), style = MonzoText.Balance, color = Color.White)
Haptics: prefer LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) as the soft impact on Pot money move and card flip. For payment success use view.performHapticFeedback(HapticFeedbackConstants.CONFIRM). Tab change gets a light CLOCK_TICK.
6. Icons
Monzo’s category glyphs are emoji (user/merchant data), not icon-font icons. UI chrome maps to androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house.fill |
Icons.Filled.Home |
| Payments (tab) | creditcard.fill |
Icons.Filled.CreditCard |
| Pots (tab) | circle.grid.2x2.fill |
Icons.Filled.GridView |
| Trends (tab) | chart.line.uptrend.xyaxis |
Icons.Filled.TrendingUp |
| Account (tab) | person.crop.circle.fill |
Icons.Filled.AccountCircle |
| Search | magnifyingglass |
Icons.Filled.Search |
| Add money | plus |
Icons.Filled.Add |
| Freeze card | snowflake |
Icons.Filled.AcUnit |
| Split bill | person.2 |
Icons.Filled.Group |
| Map pin | mappin.circle.fill |
Icons.Filled.LocationOn |
| Receipt | doc.text |
Icons.Filled.Receipt |
| Notes | square.and.pencil |
Icons.Filled.Edit |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Settings | gearshape |
Icons.Filled.Settings |
| Income | arrow.down.left |
Icons.Filled.SouthWest |
| Pending | clock |
Icons.Filled.Schedule |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
graphicsLayerrotation + modern motion comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); deep-navy canvas wants light-content system bars. The card hero and feed respect the camera cutout; sheets respect the IME withModifier.imePadding(). - Tabular figures: set
fontFeatureSettings = "tnum"on balance and amount styles so money columns align; verify on your bundled Inter. - Font scaling:
sphonors the user’s font scale — keep it on screen title, balance, section, body, merchant, meta. Pin layout-sensitive text (10sp tab labels, card number, Pot-coin captions) by deriving fromdpor a fixed-fontScaleCompositionLocalProvider. - TalkBack: label transaction rows “{merchant}, {category}, {amount}, {time}{, pending}”; the card hero is a button — “Current account, balance {amount}, double-tap to show card details”; expose Pot progress as a
progressBarRangeInfo. - Touch targets: Material guidance is 48.dp. Transaction rows are full-row tappable (≥56.dp effective); give 22.dp tab icons a 48.dp hit area; primary buttons ≥ 48.dp.
- Contrast:
#14233Con#F6F7F9and#F4F6F9on#0E1620pass WCAG AA for body. The category-tint circles use ~18% alpha fills behind a solid emoji — fine; validate any solid-colored chip text (especially gold) with a contrast checker. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the card flip (Crossfadebetween faces), the Pot coin spring (snap to target), and the balance ticker (set value directly); keep the income/spend color cue. - Dark mode: invert via the
Dark*palette —#0E1620deep navy, NOT true black;#14233Cis the surface;#F4F6F9text. Shadows are weak on dark, so add a 1dpDarkDividerborder to sheets as the elevation cue. Do not enabledynamicColorScheme()— Hot Coral is the brand and must hold regardless of wallpaper.