coderef

Wise

Wise's iOS app (formerly TransferWise) is a bright, confident money app whose entire personality is transparency. The canvas is clean white (#FFFFFF) with neutral surfaces (#F7F7F7), and the brand's signature deep forest green (#163300)…
DownloadRaw
Wise

Build with the Wise look.

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

Palette

At a glance

Theme
light
Typefaces
Wise Sans
Sections
8
Length
3,344 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Wise

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

Wise (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Wise’s visual language to Android with Jetpack Compose (Material 3): a color token object, 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 (Wise’s bright canvas, forest structural ink, bright-green action, the fee-transparency card, the multi-currency stack) while making everything idiomatic Android — NavigationBar instead of a UITabBar, sp/dp instead of pt, AnimatedContent for the number roll-up.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for images.

1. Color Tokens

// ui/theme/WiseColors.kt
import androidx.compose.ui.graphics.Color

object WiseColors {
    // Canvas & Surfaces
    val Canvas        = Color(0xFFFFFFFF)
    val Surface       = Color(0xFFF7F7F7)
    val SurfaceSunken = Color(0xFFEFEFEF)
    val Divider       = Color(0xFFE5E5E5)
    val Border        = Color(0xFFD2D2D2)

    // Text
    val TextPrimary   = Color(0xFF0E0F0C)
    val TextSecondary = Color(0xFF6B6F66)
    val TextTertiary  = Color(0xFF9A9D95)

    // Brand
    val Bright        = Color(0xFF9FE870)
    val BrightPressed = Color(0xFF8AD45C)
    val BrightTint    = Color(0xFFEAF9DC)
    val Forest        = Color(0xFF163300)
    val ForestHover   = Color(0xFF0E2200)

    // Semantic
    val Success = Color(0xFF2F8F4E)
    val Pending = Color(0xFFB5781E)
    val Error   = Color(0xFFD4332B)
}

Wire it into a Material 3 lightColorScheme. Wise is light-first; provide a dark scheme only if you target system dark mode (keep Bright unchanged with Forest content).

// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme

private val WiseScheme = lightColorScheme(
    primary          = WiseColors.Bright,
    onPrimary        = WiseColors.Forest,   // forest on bright green, never white
    primaryContainer = WiseColors.BrightTint,
    secondary        = WiseColors.Forest,
    onSecondary      = Color.White,
    background        = WiseColors.Canvas,
    onBackground      = WiseColors.TextPrimary,
    surface           = WiseColors.Canvas,
    onSurface         = WiseColors.TextPrimary,
    surfaceVariant    = WiseColors.Surface,
    outline           = WiseColors.Border,
    outlineVariant    = WiseColors.Divider,
    error             = WiseColors.Error,
)

@Composable
fun WiseTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = WiseScheme, typography = WiseTypography, content = content)

2. Typography

Wise Sans 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/WiseType.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 WiseSans = FontFamily(
    Font(R.font.wise_sans_regular,   FontWeight.Normal),    // 400
    Font(R.font.wise_sans_semibold,  FontWeight.SemiBold),  // 600
    Font(R.font.wise_sans_extrabold, FontWeight.ExtraBold), // 800
)

private const val TNUM = "tnum"

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object WiseText {
    val Balance    = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 40.sp, lineHeight = 42.sp, letterSpacing = (-0.6).sp, fontFeatureSettings = TNUM)
    val TitleLarge = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
    val Section    = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
    val Currency   = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp, fontFeatureSettings = TNUM)
    val Subsection = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold,  fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
    val Amount     = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold,  fontSize = 16.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
    val Title      = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold,  fontSize = 16.sp, lineHeight = 21.sp)
    val Body       = TextStyle(WiseSans, fontWeight = FontWeight.Normal,    fontSize = 15.sp, lineHeight = 22.sp)
    val Button     = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 20.sp)
    val Meta       = TextStyle(WiseSans, fontWeight = FontWeight.Normal,    fontSize = 13.sp, lineHeight = 17.sp)
    val LabelUpper = TextStyle(WiseSans, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
    val Tab        = TextStyle(WiseSans, fontWeight = FontWeight.SemiBold,  fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.1.sp)
    val Caption    = TextStyle(WiseSans, fontWeight = FontWeight.Normal,    fontSize = 11.sp, lineHeight = 14.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val WiseTypography = Typography(
    headlineLarge = WiseText.TitleLarge,
    headlineSmall = WiseText.Section,
    titleMedium   = WiseText.Title,
    bodyMedium    = WiseText.Body,
    labelSmall    = WiseText.Tab,
)

3. Signature Components

Primary CTA (Bright Green)

import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.ui.draw.scale
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

@Composable
fun WisePrimaryButton(
    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)
            .clip(RoundedCornerShape(16.dp))
            .background(if (pressed) WiseColors.BrightPressed else WiseColors.Bright)
            .heightIn(min = 52.dp)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS light impact
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(text, style = WiseText.Button, color = WiseColors.Forest) // forest, never white
    }
}

@Composable
fun WiseForestButton(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) WiseColors.ForestHover else WiseColors.Forest)
            .heightIn(min = 52.dp)
            .clickable(interaction, indication = null, onClick = onClick),
        contentAlignment = Alignment.Center,
    ) { Text(text, style = WiseText.Button, color = Color.White) }
}

Forest Account Hero (number roll-up)

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith

@Composable
fun ForestAccountHero(total: String, modifier: Modifier = Modifier) {
    Column(
        modifier
            .clip(RoundedCornerShape(20.dp))
            .background(WiseColors.ForestHover)
            .padding(24.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text("TOTAL BALANCE", style = WiseText.LabelUpper, color = WiseColors.Bright)
        AnimatedContent(
            targetState = total,
            transitionSpec = {
                (slideInVertically { it / 2 } togetherWith slideOutVertically { -it / 2 })
            },
            label = "balanceRoll",
        ) { value ->
            Text(value, style = WiseText.Balance, color = Color.White)
        }
        Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Icon(Icons.Filled.Add, contentDescription = null, tint = WiseColors.Bright, modifier = Modifier.size(16.dp))
            Text("Add money", style = WiseText.Title, color = WiseColors.Bright)
        }
    }
}

Currency Balance Row

@Composable
fun CurrencyRow(
    flag: String,
    code: String,
    name: String,
    balance: String,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column {
        Row(
            modifier
                .fillMaxWidth()
                .background(if (pressed) WiseColors.Surface else WiseColors.Canvas)
                .clickable(interaction, indication = null) {}
                .height(64.dp)
                .padding(horizontal = 16.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(
                Modifier.size(32.dp).clip(CircleShape).background(WiseColors.Surface),
                contentAlignment = Alignment.Center,
            ) { Text(flag, fontSize = 18.sp) }
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                Text(code, style = WiseText.Title, color = WiseColors.TextPrimary)
                Text(name, style = WiseText.Meta, color = WiseColors.TextSecondary)
            }
            Text(balance, style = WiseText.Currency, color = WiseColors.TextPrimary)
        }
        Divider(color = WiseColors.Divider, thickness = 1.dp)
    }
}

Fee-Transparency Card (signature)

data class FeeLine(val label: String, val value: String, val emphasized: Boolean = false)

@Composable
fun FeeBreakdownCard(lines: List<FeeLine>, modifier: Modifier = Modifier) {
    Column(
        modifier
            .clip(RoundedCornerShape(16.dp))
            .background(WiseColors.Canvas)
            .border(1.dp, WiseColors.Divider, RoundedCornerShape(16.dp))
            .padding(20.dp),
    ) {
        lines.forEachIndexed { i, line ->
            Row(
                Modifier
                    .fillMaxWidth()
                    .then(if (line.emphasized) Modifier.clip(RoundedCornerShape(10.dp)).background(WiseColors.BrightTint) else Modifier)
                    .padding(horizontal = if (line.emphasized) 12.dp else 0.dp, vertical = 12.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(
                    line.label,
                    style = if (line.emphasized) WiseText.Title else WiseText.Body,
                    color = if (line.emphasized) WiseColors.Forest else WiseColors.TextSecondary,
                )
                Text(
                    line.value,
                    style = if (line.emphasized) WiseText.Subsection else WiseText.Amount,
                    color = if (line.emphasized) WiseColors.Forest else WiseColors.TextPrimary,
                )
            }
            if (i < lines.lastIndex && !line.emphasized) {
                Divider(color = WiseColors.Divider, thickness = 1.dp)
            }
        }
    }
}

Send-Money Stepper (signature)

@Composable
fun SendStepper(steps: List<String>, current: Int, modifier: Modifier = Modifier) {
    Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
        steps.forEachIndexed { i, _ ->
            val done = i < current
            val active = i == current
            Box(
                Modifier
                    .size(28.dp)
                    .clip(CircleShape)
                    .background(if (done) WiseColors.Bright else if (active) WiseColors.Forest else WiseColors.Divider),
                contentAlignment = Alignment.Center,
            ) {
                if (done) {
                    Icon(Icons.Filled.Check, contentDescription = null, tint = WiseColors.Forest, modifier = Modifier.size(14.dp))
                } else {
                    Text(
                        "${i + 1}",
                        style = WiseText.Title.copy(fontSize = 13.sp),
                        color = if (active) Color.White else WiseColors.TextSecondary,
                    )
                }
            }
            if (i < steps.lastIndex) {
                Box(
                    Modifier
                        .weight(1f)
                        .height(2.dp)
                        .background(if (done) WiseColors.Forest else WiseColors.Divider),
                )
            }
        }
    }
}

4. Number Roll-Up Helper

// Animate a numeric balance with AnimatedContent + vertical slide; ~500ms.
import androidx.compose.animation.core.tween

@Composable
fun RollingBalance(value: String) {
    AnimatedContent(
        targetState = value,
        transitionSpec = {
            slideInVertically(tween(500)) { it } togetherWith slideOutVertically(tween(500)) { -it }
        },
        label = "rollingBalance",
    ) { Text(it, style = WiseText.Balance, color = Color.White) }
}

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. Wise’s iOS tab bar is opaque white with a hairline top border. Active tint is forest; optionally mark the active tab with a bright-green dot.

@Composable
fun WiseBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(
        containerColor = WiseColors.Canvas,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"       to Icons.Filled.Home,
            "Card"       to Icons.Filled.CreditCard,
            "Recipients" to Icons.Filled.People,
            "Payments"   to Icons.Filled.SwapHoriz,
            "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(24.dp)) },
                label = { Text(label, style = WiseText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = WiseColors.Forest,
                    selectedTextColor   = WiseColors.Forest,
                    unselectedIconColor = WiseColors.TextSecondary,
                    unselectedTextColor = WiseColors.TextSecondary,
                    indicatorColor      = WiseColors.BrightTint,
                ),
            )
        }
    }
}

Place a hairline Divider(color = WiseColors.Divider, thickness = 0.5.dp) directly above the bar; the send flow’s sticky “Continue” sits in the Scaffold bottomBar slot above it.

6. Motion

Moment Compose recipe
Number roll-up AnimatedContent + slideInVertically/slideOutVertically tween(500) on the balance string
CTA press animateFloatAsState scale 1→0.98 tween(150) + BrightPressed fill; HapticFeedbackType.LongPress
Stepper advance animate dot background + rail color with animateColorAsState(tween(200)) on current
Rate ticker pulse rememberInfiniteTransition driving a small dot’s alpha 0.3↔1, tween(1000) reverse
Fee card reveal stagger rows with AnimatedVisibility + fadeIn + per-row delayMillis = i * 40
Segmented thumb animateDpAsState pill offset, tween(220) ease-in-out

Haptics: prefer LocalHapticFeedback. For a softer iOS-like confirmation use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, …).

7. Icons

Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Wise glyphs as vector drawables and load via ImageVector.vectorResource(...).

Purpose SF Symbol (iOS) Material Icon (Compose)
Home (tab) house.fill Icons.Filled.Home
Card (tab) creditcard.fill Icons.Filled.CreditCard
Recipients (tab) person.2.fill Icons.Filled.People
Payments (tab) arrow.left.arrow.right Icons.Filled.SwapHoriz
Account (tab) person.crop.circle.fill Icons.Filled.AccountCircle
Send paperplane.fill Icons.Filled.Send
Add money plus Icons.Filled.Add
Request arrow.down.left Icons.Filled.SouthWest
Convert arrow.left.arrow.right.circle Icons.Filled.SwapHorizontalCircle
Search magnifyingglass Icons.Filled.Search
Notifications bell.fill Icons.Filled.Notifications
Statement doc.text Icons.Filled.Description
Step complete checkmark Icons.Filled.Check
Money in arrow.down.left Icons.Filled.SouthWest
Rate up / down arrow.up / arrow.down Icons.Filled.ArrowUpward / ArrowDownward

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; AnimatedContent and modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); the bright canvas wants dark-content system bars (WindowCompat). Apply Scaffold insets so the send flow’s sticky bar clears gesture nav.
  • Font scaling: sp honors the user’s scale — keep it on titles, body, amounts. Pin the hero total, tab labels, uppercase labels, and currency-row figures with a fixed Density wrapper for column alignment.
  • TalkBack: announce the total with currency; read each fee line as “label, value” and mark the “Recipient gets” row with Modifier.semantics { heading() }; announce stepper state (“Step 2 of 4, Amount”).
  • Touch targets: Material minimum is 48.dp. The 52.dp CTA is clear; give the 24.dp tab glyphs and 28.dp stepper dots a 48.dp hit area via padding.
  • Contrast: forest #163300 on bright green #9FE870 passes WCAG AAA — never white on bright green. #6B6F66 on white passes AA at 14sp+; bump 11sp labels toward #5A5E55 for strict compliance.
  • Reduced motion: honor Settings.Global.ANIMATOR_DURATION_SCALE == 0 by setting the balance instantly and crossfading the stepper instead of sliding.
  • Dynamic color: do not enable Material You dynamicLightColorScheme() — Wise’s brand requires the fixed forest #163300 and bright green #9FE870 regardless of wallpaper.

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