SoFi (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports SoFi’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, the gradient member hero, account tiles, cross-sell cards, the activity row, navigation, and motion.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (SoFi’s dark-native navy + electric blue, the gradient member hero, the consolidated tile grid, pill buttons, color-coded performance) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, aBrush.linearGradienthero,dp/spinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for any avatars. No color extraction — SoFi’s palette is fixed brand colors, so Palette is not needed. SoFi is dark-native: navy is the canonical canvas in both themes; a light scheme is provided that lifts surfaces while keeping the navy + electric-blue identity.
1. Color Tokens
// ui/theme/SofiColors.kt
import androidx.compose.ui.graphics.Color
object SofiColors {
// Brand (Blue)
val Blue = Color(0xFF00A0DF)
val BlueBright = Color(0xFF29C2FF)
val BluePressed = Color(0xFF0086BC)
val BlueSoft = Color(0xFFE6F4FB) // light
val BlueSoftDark = Color(0xFF0E2A44) // dark-native
val OnBlueInk = Color(0xFF042235)
// Canvas & Surfaces (dark-native — canonical)
val Navy = Color(0xFF0A0E27)
val Surface1 = Color(0xFF121736)
val Surface2 = Color(0xFF1B2147)
val Divider = Color(0xFF252C55)
// Canvas & Surfaces (light)
val LightCanvas = Color(0xFFF4F7FB)
val LightSurface1 = Color(0xFFFFFFFF)
val LightSurface2 = Color(0xFFEEF2F8)
val LightDivider = Color(0xFFE2E7F0)
// Text (on navy)
val TextPrimary = Color(0xFFE8EBF7)
val TextSecondary = Color(0xFF9BA3C7)
val TextTertiary = Color(0xFF6B7299)
val TextPrimaryLight = Color(0xFF0C1330)
val TextSecondaryLight = Color(0xFF5A6286)
// Semantic
val Positive = Color(0xFF2FD08A) // navy
val PositiveLight = Color(0xFF0FA968) // light
val Negative = Color(0xFFFF6B6B) // navy
val NegativeLight = Color(0xFFE0484D) // light
val Gold = Color(0xFFF2C14E) // navy
val GoldLight = Color(0xFFC8920F) // light
val PillGreen = Color(0xFF6EF0B6) // change-pill text on hero
val GradientMid = Color(0xFF1B53C4) // hero gradient middle stop
}
Wire it into both schemes. SoFi is dark-native — navy #0A0E27 is canonical; the light scheme lifts surfaces but the navy + electric-blue identity holds.
// 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 SofiDark = darkColorScheme(
primary = SofiColors.Blue,
onPrimary = SofiColors.OnBlueInk,
background = SofiColors.Navy,
onBackground = SofiColors.TextPrimary,
surface = SofiColors.Surface1,
onSurface = SofiColors.TextPrimary,
surfaceVariant = SofiColors.Surface2,
outline = SofiColors.Divider,
error = SofiColors.Negative,
)
private val SofiLight = lightColorScheme(
primary = SofiColors.Blue,
onPrimary = SofiColors.OnBlueInk,
background = SofiColors.LightCanvas,
onBackground = SofiColors.TextPrimaryLight,
surface = SofiColors.LightSurface1,
onSurface = SofiColors.TextPrimaryLight,
surfaceVariant = SofiColors.LightSurface2,
outline = SofiColors.LightDivider,
error = SofiColors.NegativeLight,
)
@Composable
fun SofiTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
// Default to dark — SoFi is dark-native; light only when the system asks.
colorScheme = if (dark) SofiDark else SofiLight,
typography = SofiTypography,
content = content,
)
2. Typography
SoFi’s type is a confident geometric sans — Manrope is the faithful free stand-in (SIL OFL; drop the TTFs in res/font/). Numbers always 700-800 and tabular (tnum).
// ui/theme/SofiType.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 Manrope = FontFamily(
Font(R.font.manrope_regular, FontWeight.Normal),
Font(R.font.manrope_medium, FontWeight.Medium),
Font(R.font.manrope_semibold, FontWeight.SemiBold),
Font(R.font.manrope_bold, FontWeight.Bold),
Font(R.font.manrope_extrabold, FontWeight.ExtraBold),
)
private const val TNUM = "tnum" // fontFeatureSettings on amount Text
object SofiText {
val NetWorth = TextStyle(Manrope, fontWeight = FontWeight.ExtraBold, fontSize = 40.sp, lineHeight = 42.sp, letterSpacing = (-1.2).sp, fontFeatureSettings = TNUM)
val ScreenTitle = TextStyle(Manrope, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 37.sp, letterSpacing = (-0.6).sp)
val Section = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val TileValue = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.4).sp, fontFeatureSettings = TNUM)
val CardTitle = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Manrope, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val RowTitle = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 20.sp)
val Amount = TextStyle(Manrope, fontWeight = FontWeight.ExtraBold, fontSize = 15.sp, lineHeight = 18.sp, fontFeatureSettings = TNUM)
val Meta = TextStyle(Manrope, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 19.sp)
val Caption = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 17.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
val Chip = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 12.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val Link = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 17.sp)
}
val SofiTypography = Typography(
headlineLarge = SofiText.NetWorth,
headlineMedium = SofiText.ScreenTitle,
titleMedium = SofiText.CardTitle,
bodyMedium = SofiText.Body,
labelSmall = SofiText.Tab,
)
3. Signature Components
Member Hero (gradient)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
@Composable
fun SofiMemberHero(
greeting: String, netWorth: String, change: String, isUp: Boolean,
) {
Column(
Modifier
.fillMaxWidth()
.background(
Brush.linearGradient(
colorStops = arrayOf(
0.0f to SofiColors.Blue,
0.45f to SofiColors.GradientMid,
1.0f to SofiColors.Navy,
),
)
)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(start = 20.dp, end = 20.dp, top = 8.dp, bottom = 28.dp),
) {
Row(
Modifier.fillMaxWidth().padding(bottom = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(greeting, style = SofiText.CardTitle.copy(fontSize = 18.sp), color = Color.White)
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Icon(Icons.Filled.NotificationsNone, null, tint = Color.White, modifier = Modifier.size(22.dp))
Icon(Icons.Filled.AccountCircle, null, tint = Color.White, modifier = Modifier.size(22.dp))
}
}
Text("Net worth", style = SofiText.Caption, color = Color.White.copy(alpha = 0.75f))
Text(netWorth, style = SofiText.NetWorth, color = Color.White, modifier = Modifier.padding(top = 4.dp))
Row(
Modifier
.padding(top = 8.dp)
.clip(RoundedCornerShape(50))
.background(SofiColors.Positive.copy(alpha = 0.18f))
.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(if (isUp) Icons.Filled.ArrowUpward else Icons.Filled.ArrowDownward,
null, tint = SofiColors.PillGreen, modifier = Modifier.size(11.dp))
Text(change, style = SofiText.Link, color = SofiColors.PillGreen)
}
}
}
Account Tile
enum class SubKind { Gain, Loss, Rewards, Neutral }
@Composable
fun SofiAccountTile(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String, value: String, subStat: String,
subKind: SubKind = SubKind.Neutral, modifier: Modifier = Modifier,
) {
val subColor = when (subKind) {
SubKind.Gain -> SofiColors.Positive
SubKind.Loss -> SofiColors.Negative
SubKind.Rewards -> SofiColors.Gold
SubKind.Neutral -> SofiColors.TextSecondary
}
Column(
modifier
.clip(RoundedCornerShape(18.dp))
.background(SofiColors.Surface1)
.border(1.dp, SofiColors.Divider, RoundedCornerShape(18.dp))
.padding(16.dp),
) {
Box(
Modifier.size(36.dp).clip(RoundedCornerShape(11.dp)).background(SofiColors.BlueSoftDark),
contentAlignment = Alignment.Center,
) { Icon(icon, null, tint = SofiColors.BlueBright, modifier = Modifier.size(18.dp)) }
Spacer(Modifier.height(12.dp))
Text(label, style = SofiText.Caption, color = SofiColors.TextSecondary)
Text(value, style = SofiText.TileValue, color = SofiColors.TextPrimary, modifier = Modifier.padding(top = 3.dp))
Text(subStat, style = SofiText.Caption, color = subColor, modifier = Modifier.padding(top = 4.dp))
}
}
// 2-column grid: LazyVerticalGrid(GridCells.Fixed(2), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { … }
Cross-Sell Product Card & Activity Row
@Composable
fun SofiProductCard(
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String, valueProp: String, modifier: Modifier = Modifier,
) {
Column(
modifier
.width(240.dp)
.clip(RoundedCornerShape(18.dp))
.background(SofiColors.Surface2)
.border(1.dp, SofiColors.Divider, RoundedCornerShape(18.dp))
.padding(16.dp),
) {
Box(Modifier.size(36.dp).clip(RoundedCornerShape(11.dp)).background(SofiColors.BlueSoftDark),
contentAlignment = Alignment.Center) {
Icon(icon, null, tint = SofiColors.BlueBright, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.height(12.dp))
Text(title, style = SofiText.CardTitle, color = SofiColors.TextPrimary)
Text(valueProp, style = SofiText.Caption.copy(fontWeight = FontWeight.Normal),
color = SofiColors.TextSecondary, modifier = Modifier.padding(top = 3.dp))
}
}
@Composable
fun SofiActivityRow(
icon: androidx.compose.ui.graphics.vector.ImageVector, tint: Color,
name: String, meta: String, amount: String, positive: Boolean,
) {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(40.dp).clip(RoundedCornerShape(13.dp)).background(SofiColors.BlueSoftDark),
contentAlignment = Alignment.Center) {
Icon(icon, null, tint = tint, modifier = Modifier.size(18.dp))
}
Column(Modifier.weight(1f)) {
Text(name, style = SofiText.RowTitle, color = SofiColors.TextPrimary)
Text(meta, style = SofiText.Meta, color = SofiColors.TextSecondary)
}
Text(amount, style = SofiText.Amount,
color = if (positive) SofiColors.Positive else SofiColors.TextPrimary)
}
HorizontalDivider(color = SofiColors.Divider)
}
Primary Button & Performance Chip
@Composable
fun SofiPrimaryButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Box(
modifier
.heightIn(min = 52.dp)
.clip(RoundedCornerShape(50)) // FULL PILL
.background(if (pressed) SofiColors.BluePressed else SofiColors.Blue)
.clickable(interaction, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) { Text(text, style = SofiText.Button, color = SofiColors.OnBlueInk, modifier = Modifier.padding(horizontal = 28.dp)) }
}
sealed interface SofiPerf {
data object Active : SofiPerf
data class Up(val v: String) : SofiPerf
data class Down(val v: String) : SofiPerf
data class Rewards(val v: String) : SofiPerf
}
@Composable
fun SofiPerfChip(perf: SofiPerf) {
val (bg, fg, text) = when (perf) {
SofiPerf.Active -> Triple(SofiColors.BlueSoftDark, SofiColors.BlueBright, "Active")
is SofiPerf.Up -> Triple(SofiColors.Positive.copy(alpha = 0.16f), SofiColors.Positive, "▲ ${perf.v}")
is SofiPerf.Down -> Triple(SofiColors.Negative.copy(alpha = 0.16f), SofiColors.Negative, "▼ ${perf.v}")
is SofiPerf.Rewards -> Triple(SofiColors.Gold.copy(alpha = 0.16f), SofiColors.Gold, perf.v)
}
Box(Modifier.clip(RoundedCornerShape(50)).background(bg).padding(horizontal = 14.dp, vertical = 7.dp)) {
Text(text, style = SofiText.Chip, color = fg)
}
}
4. Navigation
SoFi has a 5-tab bottom strip — Home, Invest, Banking, Borrow, Me. On Android, model it as a NavigationBar; active is bright blue with the filled icon (no Material tint pill).
@Composable
fun SofiBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = SofiColors.Navy, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Invest" to Icons.Filled.TrendingUp,
"Banking" to Icons.Filled.CreditCard,
"Borrow" to Icons.Filled.AccountBalance,
"Me" 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 = SofiText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = SofiColors.BlueBright,
selectedTextColor = SofiColors.BlueBright,
unselectedIconColor = SofiColors.TextTertiary,
unselectedTextColor = SofiColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — SoFi has none
),
)
}
}
}
The Transfer / Invest flow is a Material 3 ModalBottomSheet (Surface1 / LightSurface1, 24dp top corners) with a huge centered amount entry, a funding-source row, and a pinned blue pill CTA. The member home’s top “header” is the gradient hero itself — there is no separate TopAppBar; cross-sell cards sit in a LazyRow with snapFlingBehavior.
5. Motion
SoFi motion is smooth — ease-outs and snaps, measured haptics. The signature is the net-worth count-up and the gradient parallax.
| Moment | Compose recipe |
|---|---|
| Net-worth count-up | animateFloatAsState previous → new, tween(600, easing = EaseOut), format as currency |
| Gradient parallax | hero Modifier.graphicsLayer { translationY = scrollState.value * -0.3f } |
| Tile press | Modifier.scale(animateFloatAsState(if (pressed) 0.97f else 1f, tween(150))) |
| Performance tick | Crossfade/animateColorAsState on the %/chip when data refreshes (tween(200)) |
| Transfer success | full-screen blue check drawWithCache stroke animateFloatAsState 0 → 1 tween(500) + medium haptic |
| Cross-sell carousel | LazyRow + rememberSnapFlingBehavior, peeking next card |
| Tab switch | instant; active icon → bright blue, no slide |
| Pull to refresh | PullToRefreshContainer blue indicator; re-count net worth + tiles if changed |
// Net-worth count-up — the canonical SoFi motion
val shown by animateFloatAsState(
targetValue = netWorth,
animationSpec = tween(600, easing = androidx.compose.animation.core.EaseOut),
label = "netWorthCount",
)
Text("$%,.2f".format(shown), style = SofiText.NetWorth, color = Color.White)
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) on transfer/invest success, a soft LocalView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) on tile and tab taps and pull-refresh release. Silent data refresh has no haptic; only success/failure does.
6. Icons
SoFi’s iconography is clean and line-based; androidx.compose.material:material-icons-extended covers it. The wordmark (“SoFi”) is brand text, not an icon-font glyph.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Home (tab) | house / house.fill |
Icons.Filled.Home |
| Invest (tab) | chart.line.uptrend.xyaxis |
Icons.Filled.TrendingUp |
| Banking (tab) | creditcard / creditcard.fill |
Icons.Filled.CreditCard |
| Borrow (tab) | building.columns / .fill |
Icons.Filled.AccountBalance |
| Me (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Checking & Savings tile | creditcard |
Icons.Filled.CreditCard |
| Invest tile | chart.line.uptrend.xyaxis |
Icons.Filled.TrendingUp |
| Loans tile | clock.arrow.circlepath |
Icons.Filled.History |
| Credit Card tile | star.fill |
Icons.Filled.Star |
| Rewards | star.circle.fill |
Icons.Filled.CardGiftcard |
| Net-worth up | arrow.up |
Icons.Filled.ArrowUpward |
| Net-worth down | arrow.down |
Icons.Filled.ArrowDownward |
| Notifications | bell |
Icons.Filled.NotificationsNone |
| Transfer | arrow.left.arrow.right |
Icons.Filled.SwapHoriz |
| Auto-invest | arrow.triangle.2.circlepath |
Icons.Filled.Repeat |
| Direct deposit | dollarsign.arrow.circlepath |
Icons.Filled.Payments |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Settings | gearshape |
Icons.Filled.Settings |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; the gradient hero + modern motion are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the gradient/navy ground is dark in both themes, so use light-content system bars throughout (even in “light mode” the hero is the gradient). The hero useswindowInsetsPadding(WindowInsets.statusBars); the TransferModalBottomSheetrespectsModifier.imePadding(). - Tabular figures: keep
fontFeatureSettings = "tnum"on net worth and every amount so columns align and the count-up doesn’t reflow. - Font scaling:
sphonors the user’s font scale — keep it on net worth, screen titles, section headers, tile values, body, row titles, meta. Pin layout-sensitive text (10sp tab labels, chip text, the change-pill text) viadp-derived sizes orCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: the hero exposes
contentDescription = "Net worth, $84,210.55, up $1,840.22 this month"; tiles announce “{label}, {value}, {sub-stat}”; activity rows announce “{name}, {meta}, {signed amount}”. Never convey performance by color alone — keep the ▲/▼ glyph and the+/-sign. - Touch targets: Material guidance is 48.dp. Tiles are full-tile tappable (≥ 96.dp); cross-sell cards full-card; primary pill buttons ≥ 52.dp; tab items full-height.
- Contrast:
#042235on#00A0DFpasses WCAG AA (the near-black blue ink is chosen for this); white on the gradient passes throughout.#2FD08Aon navy passes for graphical/large text — pair with the arrow glyph for small %s. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, set net worth instantly (no roll), skip the gradient parallax and the check-stroke draw; keep tile press as a plain state change. - Dark-native / light: navy
#0A0E27is the canonical canvas. The light scheme lifts surfaces (Surface1 → white,BlueSoftDark → BlueSoft,Positive → PositiveLight, etc.) but the hero gradient and#042235ink stay constant and the navy + electric-blue identity must remain. Do not enable Material YoudynamicColorScheme()— SoFi’s electric-blue-on-navy brand must hold regardless of wallpaper.