Revolut (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Revolut’s visual language to Android with Jetpack Compose (Material 3): a color token object, the brand gradient, a Typography set, paste-ready @Composables, 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 (Revolut’s cool near-black canvas, the violet→purple brand gradient, the metal-card hero, the spend donut) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,Brush.linearGradientinstead ofLinearGradient,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.
1. Color Tokens
// ui/theme/RevolutColors.kt
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
object RevolutColors {
// Canvas & Surfaces
val Canvas = Color(0xFF0A0A0F)
val Surface1 = Color(0xFF16161F)
val Surface2 = Color(0xFF1E1E2A)
val Surface3 = Color(0xFF28283A)
val Divider = Color(0xFF2A2A38)
val Border = Color(0xFF33334A)
// Text
val TextPrimary = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF9A9AAA)
val TextTertiary = Color(0xFF6A6A7E)
// Brand
val GradStart = Color(0xFF5B6BFF)
val GradEnd = Color(0xFF9C6BFF)
val Brand = Color(0xFF6B5BFF) // solid fallback
val BrandPressed = Color(0xFF5648D6)
val BrandTint = Color(0xFF1C1B33)
// Semantic
val Income = Color(0xFF1FD17B)
val Spend = Color(0xFFFF5A6A)
val Warn = Color(0xFFFFB23F)
val Crypto = Color(0xFFF7C948)
}
val RevolutBrandGradient = Brush.linearGradient(
listOf(RevolutColors.GradStart, RevolutColors.GradEnd)
)
Wire it into a Material 3 darkColorScheme. Revolut is dark-first; provide a light scheme only if you target system light mode (use BrandPressed solid, no gradient ghosting).
// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
private val RevolutScheme = darkColorScheme(
primary = RevolutColors.Brand,
onPrimary = Color.White,
primaryContainer = RevolutColors.BrandTint,
background = RevolutColors.Canvas,
onBackground = RevolutColors.TextPrimary,
surface = RevolutColors.Surface1,
onSurface = RevolutColors.TextPrimary,
surfaceVariant = RevolutColors.Surface2,
outline = RevolutColors.Border,
outlineVariant = RevolutColors.Divider,
error = RevolutColors.Spend,
)
@Composable
fun RevolutTheme(content: @Composable () -> Unit) =
MaterialTheme(colorScheme = RevolutScheme, typography = RevolutTypography, content = content)
2. Typography
Aeonik is proprietary. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to the system font (Roboto); Inter is the closest substitute if bundled.
// ui/theme/RevolutType.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 Aeonik = FontFamily(
Font(R.font.aeonik_regular, FontWeight.Normal), // 400
Font(R.font.aeonik_medium, FontWeight.Medium), // 500/600
Font(R.font.aeonik_bold, FontWeight.Bold), // 700
)
private const val TNUM = "tnum"
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object RevolutText {
val Balance = TextStyle(Aeonik, fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 42.sp, letterSpacing = (-0.6).sp, fontFeatureSettings = TNUM)
val TitleLarge = TextStyle(Aeonik, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Aeonik, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val TileBalance = TextStyle(Aeonik, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp, fontFeatureSettings = TNUM)
val Subsection = TextStyle(Aeonik, fontWeight = FontWeight.Medium, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Amount = TextStyle(Aeonik, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
val Merchant = TextStyle(Aeonik, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Aeonik, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Button = TextStyle(Aeonik, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp)
val Meta = TextStyle(Aeonik, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val LabelUpper = TextStyle(Aeonik, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Tab = TextStyle(Aeonik, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.2.sp)
val Caption = TextStyle(Aeonik, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val RevolutTypography = Typography(
headlineLarge = RevolutText.TitleLarge,
headlineSmall = RevolutText.Section,
titleMedium = RevolutText.Merchant,
bodyMedium = RevolutText.Body,
labelSmall = RevolutText.Tab,
)
3. Signature Components
Primary Gradient CTA
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun RevPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "ctaScale")
val haptics = LocalHapticFeedback.current
Box(
modifier = modifier
.scale(scale)
.alpha(if (pressed) 0.85f else 1f)
.clip(RoundedCornerShape(16.dp))
.background(RevolutBrandGradient)
.heightIn(min = 52.dp)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS light impact
onClick()
},
contentAlignment = Alignment.Center,
) {
Text(text, style = RevolutText.Button, color = Color.White)
}
}
@Composable
fun RevSecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Box(
modifier
.clip(RoundedCornerShape(16.dp))
.background(if (pressed) RevolutColors.Surface3 else RevolutColors.Surface2)
.heightIn(min = 52.dp)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) { Text(text, style = RevolutText.Button, color = Color.White) }
}
Currency Balance Tile
@Composable
fun CurrencyTile(
flag: String,
code: String,
name: String,
balance: String,
modifier: Modifier = Modifier,
) {
Row(
modifier
.clip(RoundedCornerShape(16.dp))
.background(RevolutColors.Surface1)
.border(1.dp, RevolutColors.Divider, RoundedCornerShape(16.dp))
.height(72.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(28.dp).clip(CircleShape).background(RevolutColors.Surface2),
contentAlignment = Alignment.Center,
) { Text(flag, fontSize = 16.sp) }
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(code, style = RevolutText.Merchant, color = RevolutColors.TextPrimary)
Text(name, style = RevolutText.Meta, color = RevolutColors.TextSecondary)
}
Text(balance, style = RevolutText.TileBalance, color = RevolutColors.TextPrimary)
}
}
Transaction Row
@Composable
fun TransactionRow(
merchant: String,
meta: String,
amount: String,
incoming: Boolean,
logoUrl: String,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
modifier
.fillMaxWidth()
.background(if (pressed) RevolutColors.Surface2 else Color.Transparent)
.clickable(interaction, indication = null) {}
.height(64.dp)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AsyncImage(
model = logoUrl, contentDescription = null,
modifier = Modifier.size(40.dp).clip(CircleShape).background(RevolutColors.Surface2),
contentScale = ContentScale.Crop,
)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(merchant, style = RevolutText.Merchant, color = RevolutColors.TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(meta, style = RevolutText.Meta, color = RevolutColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text(amount, style = RevolutText.Amount, color = if (incoming) RevolutColors.Income else RevolutColors.TextPrimary)
}
}
Spend Analytics Donut
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
@Composable
fun SpendDonut(total: String, progress: Float, modifier: Modifier = Modifier) {
val anim = remember { Animatable(0f) }
LaunchedEffect(progress) { anim.animateTo(progress, tween(700)) }
Box(
modifier
.clip(RoundedCornerShape(20.dp))
.background(RevolutColors.Surface1)
.padding(20.dp),
contentAlignment = Alignment.Center,
) {
Canvas(Modifier.size(180.dp)) {
val stroke = 14.dp.toPx()
drawCircle(RevolutColors.Surface3, style = Stroke(stroke), radius = (size.minDimension - stroke) / 2)
drawArc(
brush = RevolutBrandGradient,
startAngle = -90f,
sweepAngle = 360f * anim.value,
useCenter = false,
style = Stroke(stroke, cap = StrokeCap.Round),
topLeft = Offset(stroke / 2, stroke / 2),
size = androidx.compose.ui.geometry.Size(size.width - stroke, size.height - stroke),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(total, style = RevolutText.Section, color = RevolutColors.TextPrimary)
Text("this month", style = RevolutText.Meta, color = RevolutColors.TextSecondary)
}
}
}
Metal Card Hero (flip + sheen)
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.graphics.graphicsLayer
@Composable
fun MetalCardHero(modifier: Modifier = Modifier) {
var flipped by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(if (flipped) 180f else 0f, tween(450), label = "flip")
val sheen = remember { Animatable(-1f) }
val haptics = LocalHapticFeedback.current
LaunchedEffect(Unit) { sheen.animateTo(1f, tween(1200)) }
Box(
modifier
.fillMaxWidth()
.aspectRatio(1.586f)
.graphicsLayer { rotationY = rotation; cameraDistance = 12f * density }
.clip(RoundedCornerShape(16.dp))
.background(Brush.linearGradient(listOf(Color(0xFF2E2E33), Color(0xFF101014))))
.clickable {
flipped = !flipped
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
) {
// diagonal sheen band
Box(
Modifier
.matchParentSize()
.graphicsLayer { translationX = sheen.value * size.width }
.background(Brush.linearGradient(listOf(Color.Transparent, Color.White.copy(alpha = 0.18f), Color.Transparent))),
)
Column(Modifier.fillMaxSize().padding(20.dp), verticalArrangement = Arrangement.SpaceBetween) {
Text("Revolut", style = RevolutText.Subsection, color = Color.White.copy(alpha = 0.9f))
Text(if (flipped) "CVV 042" else "•••• 4821", style = RevolutText.Amount, color = Color.White)
}
}
}
4. Brand Gradient Helper
// Tint a glyph with the brand gradient (mask via blend) — or use RevolutColors.Brand
// for tiny icons / focus rings where a gradient is impractical.
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.drawWithContent
fun Modifier.brandGradientTint() = this
.graphicsLayer(alpha = 0.99f)
.drawWithContent {
drawContent()
drawRect(RevolutBrandGradient, blendMode = BlendMode.SrcAtop)
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Revolut’s iOS tab bar is .regularMaterial blur over a near-black; Android has no first-class live blur, so use a 92%-opaque canvas. Active tint is the brand color.
@Composable
fun RevolutBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = RevolutColors.Canvas.copy(alpha = 0.92f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Invest" to Icons.Filled.TrendingUp,
"Crypto" to Icons.Filled.CurrencyBitcoin,
"Lifestyle" to Icons.Filled.AutoAwesome,
"Cards" to Icons.Filled.CreditCard,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = RevolutText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = RevolutColors.Brand,
selectedTextColor = RevolutColors.TextPrimary,
unselectedIconColor = RevolutColors.TextTertiary,
unselectedTextColor = RevolutColors.TextTertiary,
indicatorColor = RevolutColors.BrandTint,
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Card flip | animateFloatAsState rotationY 0↔180 tween(450), graphicsLayer{ cameraDistance }, HapticFeedbackType.LongPress on reveal |
| Card sheen | Animatable(-1f → 1f) tween(1200) driving translationX of a translucent band on appear/flip |
| Gradient CTA press | animateFloatAsState scale 1→0.98 + alpha 1→0.85, tween(150) |
| Donut draw | Animatable(0f → progress) tween(700) feeding drawArc sweep on entry |
| Balance reveal | animateFloatAsState blur radius via Modifier.blur 0↔12.dp, tween(250) |
| Segmented thumb | animateDpAsState gradient-pill offset, tween(220) ease-in-out |
Haptics: prefer LocalHapticFeedback. For a richer card-flip “thunk” use LocalView.current.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) or a Vibrator VibrationEffect.createOneShot(14, …).
7. Icons
Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Revolut glyphs as vector drawables and load via ImageVector.vectorResource(...).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house.fill |
Icons.Filled.Home |
| Invest (tab) | chart.line.uptrend.xyaxis |
Icons.Filled.TrendingUp |
| Crypto (tab) | bitcoinsign.circle.fill |
Icons.Filled.CurrencyBitcoin |
| Lifestyle (tab) | sparkles |
Icons.Filled.AutoAwesome |
| Cards (tab) | creditcard.fill |
Icons.Filled.CreditCard |
| Add money | plus |
Icons.Filled.Add |
| Exchange | arrow.left.arrow.right |
Icons.Filled.SwapHoriz |
| Freeze card | snowflake |
Icons.Filled.AcUnit |
| Send / pay | paperplane.fill |
Icons.Filled.Send |
| Contactless | wave.3.right |
Icons.Filled.Contactless |
| Search | magnifyingglass |
Icons.Filled.Search |
| Notifications | bell.fill |
Icons.Filled.Notifications |
| Statement | doc.text |
Icons.Filled.Description |
| Income arrow | arrow.down.left |
Icons.Filled.SouthWest |
| Outgoing arrow | arrow.up.right |
Icons.Filled.NorthEast |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; gradient + 3D motion are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the near-black canvas wants light-content system bars (WindowCompat). ApplyScaffoldinsets so the tab bar clears gesture nav. - Font scaling:
sphonors the user’s scale — keep it on titles, body, amounts. Pin the hero balance, tab labels, uppercase labels, and currency-tile figures with a fixedDensitywrapper for column alignment. - TalkBack: announce balances with currency; merge transaction-row text with
Modifier.semantics(mergeDescendants = true)and state direction (“Sent 9 pounds 99 to Spotify”); label the card hero (“Card, double tap to reveal details”). - Touch targets: Material minimum is 48.dp. The 52.dp CTA and 56.dp quick-action circles are clear; give the 24.dp tab glyphs and small icons a 48.dp hit area via padding.
- Contrast:
#9A9AAAon#0A0A0Fpasses WCAG AA at 14sp+; bump 11sp labels toward#B0B0C0for strict compliance. White on the gradient passes against both stops. - Reduced motion / transparency: honor
Settings.Global.ANIMATOR_DURATION_SCALE == 0by skipping the sheen and instant-drawing the donut/flip; if transparency is reduced, use a solidCanvas.copy(alpha = 0.98f)tab background. - Dynamic color: do not enable Material You
dynamicDarkColorScheme()— Revolut’s brand requires the fixed#0A0A0Fcanvas and the violet→purple gradient regardless of wallpaper.