coderef

Revolut

Revolut's iOS app is a near-black financial cockpit where money looks engineered, not nostalgic. The canvas sits at #0A0A0F — a cool, almost-black with the faintest blue cast — so that the product's signature gradient (an electric…
DownloadRaw
Revolut

Build with the Revolut look.

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

Palette

At a glance

Theme
dark
Typefaces
Aeonik
Sections
8
Length
3,264 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Revolut

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

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 — NavigationBar instead of a UITabBar, Brush.linearGradient instead of LinearGradient, sp/dp instead of pt.

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 BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the near-black canvas wants light-content system bars (WindowCompat). Apply Scaffold insets so the tab bar clears gesture nav.
  • Font scaling: sp honors the user’s scale — keep it on titles, body, amounts. Pin the hero balance, tab labels, uppercase labels, and currency-tile figures with a fixed Density wrapper 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: #9A9AAA on #0A0A0F passes WCAG AA at 14sp+; bump 11sp labels toward #B0B0C0 for strict compliance. White on the gradient passes against both stops.
  • Reduced motion / transparency: honor Settings.Global.ANIMATOR_DURATION_SCALE == 0 by skipping the sheen and instant-drawing the donut/flip; if transparency is reduced, use a solid Canvas.copy(alpha = 0.98f) tab background.
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — Revolut’s brand requires the fixed #0A0A0F canvas and the violet→purple gradient regardless of wallpaper.
All mobile designs →

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