coderef

Charles Schwab

The Charles Schwab iOS app is the digital lobby of a 50-year-old brokerage — confident, trustworthy, and built to make handling six- and seven-figure accounts feel safe rather than thrilling.
DownloadRaw
Charles Schwab

Build with the Charles Schwab look.

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

Palette

At a glance

Theme
light
Typefaces
Source Sans 3
Sections
7
Length
4,238 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Charles Schwab

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

Charles Schwab (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Schwab’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the account→trade spine, 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 (Schwab’s maritime navy, the single bright-blue action, the navy gradient hero, the trade ticket as centerpiece, tabular money, sacred gain/loss) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a Brush.linearGradient hero, SegmentedButton-style toggle, dp/sp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for any imagery. No color extraction — Schwab’s palette is a fixed institutional set. Schwab is light-first but ships a full maritime-navy dark scheme.

1. Color Tokens

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

object SchwabColors {
    // Brand
    val Blue        = Color(0xFF009DDC)
    val BluePressed = Color(0xFF0080B5)
    val Navy        = Color(0xFF003B5C)
    val NavyDeep    = Color(0xFF002A42)
    val Sky         = Color(0xFF46BEEC)
    val SkyLight    = Color(0xFF0078A8)
    val OnBlue      = Color(0xFF002233) // text ON Schwab Blue (white fails contrast)

    // Canvas & Surfaces (Light)
    val Canvas         = Color(0xFFFFFFFF)
    val Surface        = Color(0xFFF2F6F8)
    val SurfacePressed = Color(0xFFE6EDF1)
    val Divider        = Color(0xFFDBE3E8)

    // Canvas & Surfaces (Dark)
    val DarkCanvas   = Color(0xFF0A1622) // maritime navy — NOT pure black
    val DarkSurface1 = Color(0xFF11212F)
    val DarkSurface2 = Color(0xFF18303F)
    val DarkDivider  = Color(0xFF1F3A4A)

    // Text
    val TextPrimary       = Color(0xFF10222E)
    val TextSecondary     = Color(0xFF5A707C)
    val TextTertiary      = Color(0xFF8597A0)
    val DarkTextPrimary   = Color(0xFFE6EEF3)
    val DarkTextSecondary = Color(0xFF9DB2BF)
    val DarkTextTertiary  = Color(0xFF6A8392)
    val OnHero            = Color(0xFFFFFFFF)
    val OnHeroSub         = Color(0xFF9DC4D8)

    // Financial semantics (sacred — gain/loss only)
    val GainLight  = Color(0xFF16895F)
    val GainDark   = Color(0xFF18B07B)
    val LossLight  = Color(0xFFC8443D)
    val LossDark   = Color(0xFFE2564E)
    val OnHeroGain = Color(0xFF5FE3B0)

    // System / accent
    val Warning = Color(0xFFC8862B)
    val Info    = Color(0xFF0078A8)
    val Gold    = Color(0xFFC8A24B)
}

/** Resolve a change value to its semantic color (flat -> secondary text). */
fun changeColor(change: Double, dark: Boolean): Color = when {
    change > 0 -> if (dark) SchwabColors.GainDark else SchwabColors.GainLight
    change < 0 -> if (dark) SchwabColors.LossDark else SchwabColors.LossLight
    else       -> if (dark) SchwabColors.DarkTextSecondary else SchwabColors.TextSecondary
}

Wire it into both schemes. Schwab is light-first; the dark scheme uses the signature #0A1622 maritime navy, never true black. Do not enable Material You dynamicColorScheme() — the banker-blue identity must hold regardless of wallpaper.

// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable

private val SchwabLight = lightColorScheme(
    primary        = SchwabColors.Blue,
    onPrimary      = SchwabColors.OnBlue, // dark navy, NOT white
    secondary      = SchwabColors.Navy,
    background     = SchwabColors.Canvas,
    onBackground   = SchwabColors.TextPrimary,
    surface        = SchwabColors.Surface,
    onSurface      = SchwabColors.TextPrimary,
    surfaceVariant = SchwabColors.SurfacePressed,
    outline        = SchwabColors.Divider,
    error          = SchwabColors.LossLight,
)

private val SchwabDark = darkColorScheme(
    primary        = SchwabColors.Blue,
    onPrimary      = SchwabColors.OnBlue,
    secondary      = SchwabColors.Sky,
    background     = SchwabColors.DarkCanvas,
    onBackground   = SchwabColors.DarkTextPrimary,
    surface        = SchwabColors.DarkSurface1,
    onSurface      = SchwabColors.DarkTextPrimary,
    surfaceVariant = SchwabColors.DarkSurface2,
    outline        = SchwabColors.DarkDivider,
    error          = SchwabColors.LossDark,
)

@Composable
fun SchwabTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) SchwabDark else SchwabLight,
    typography = SchwabTypography,
    content = content,
)

2. Typography

Schwab’s brand face is “Charles Modern”; bundle Source Sans 3 (SIL OFL) in res/font/ as the closest free match. Defining rule: every monetary figure carries tabular figures via fontFeatureSettings = "tnum".

// ui/theme/SchwabType.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 SourceSans = FontFamily(
    Font(R.font.sourcesans3_regular,   FontWeight.Normal),
    Font(R.font.sourcesans3_medium,    FontWeight.Medium),
    Font(R.font.sourcesans3_semibold,  FontWeight.SemiBold),
    Font(R.font.sourcesans3_bold,      FontWeight.Bold),
    Font(R.font.sourcesans3_extrabold, FontWeight.ExtraBold),
)

private const val TNUM = "tnum"

object SchwabText {
    val TotalValue  = TextStyle(SourceSans, fontWeight = FontWeight.ExtraBold, fontSize = 34.sp, lineHeight = 38.sp, letterSpacing = (-0.6).sp, fontFeatureSettings = TNUM)
    val ScreenTitle = TextStyle(SourceSans, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
    val Section     = TextStyle(SourceSans, fontWeight = FontWeight.Bold,      fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.2).sp)
    val CardTitle   = TextStyle(SourceSans, fontWeight = FontWeight.Bold,      fontSize = 18.sp, lineHeight = 23.sp)
    val Body        = TextStyle(SourceSans, fontWeight = FontWeight.Normal,    fontSize = 16.sp, lineHeight = 24.sp)
    val RowValue    = TextStyle(SourceSans, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 21.sp, fontFeatureSettings = TNUM)
    val AcctName    = TextStyle(SourceSans, fontWeight = FontWeight.Bold,      fontSize = 15.sp, lineHeight = 20.sp)
    val Meta        = TextStyle(SourceSans, fontWeight = FontWeight.Normal,    fontSize = 14.sp, lineHeight = 19.sp)
    val NumericMono = TextStyle(SourceSans, fontWeight = FontWeight.Bold,      fontSize = 13.sp, lineHeight = 17.sp, fontFeatureSettings = TNUM)
    val Caption     = TextStyle(SourceSans, fontWeight = FontWeight.SemiBold,  fontSize = 12.sp, lineHeight = 16.sp)
    val Button      = TextStyle(SourceSans, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
    val Tab         = TextStyle(SourceSans, fontWeight = FontWeight.SemiBold,  fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.1.sp)
    val TicketField = TextStyle(SourceSans, fontWeight = FontWeight.ExtraBold, fontSize = 16.sp, lineHeight = 16.sp, fontFeatureSettings = TNUM)
}

val SchwabTypography = Typography(
    headlineLarge  = SchwabText.TotalValue,
    headlineMedium = SchwabText.ScreenTitle,
    titleMedium    = SchwabText.CardTitle,
    bodyMedium     = SchwabText.Body,
    labelSmall     = SchwabText.Tab,
)

3. Signature Components

Account Hero (navy gradient — the lead)

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.unit.dp

@Composable
fun AccountHero(
    title: String, totalValue: String,
    changeAmount: String, changePercent: String, change: Double,
    onBell: () -> Unit,
) {
    Column(
        Modifier.fillMaxWidth()
            .background(Brush.linearGradient(
                listOf(SchwabColors.Navy, SchwabColors.NavyDeep),
                start = Offset(0f, 0f), end = Offset.Infinite))
            .padding(horizontal = 20.dp).padding(top = 8.dp, bottom = 22.dp),
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Text(title, style = SchwabText.ScreenTitle, color = SchwabColors.OnHero, modifier = Modifier.weight(1f))
            IconButton(onClick = onBell, modifier = Modifier.size(34.dp)
                .clip(CircleShape).background(SchwabColors.OnHero.copy(alpha = 0.10f))) {
                Icon(Icons.Filled.Notifications, contentDescription = "Alerts", tint = SchwabColors.OnHero, modifier = Modifier.size(16.dp))
            }
        }
        Text("TOTAL VALUE", style = SchwabText.Caption, color = SchwabColors.OnHeroSub,
             modifier = Modifier.padding(top = 18.dp))
        Text(totalValue, style = SchwabText.TotalValue, color = SchwabColors.OnHero,
             modifier = Modifier.padding(top = 4.dp))
        Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
            Text(if (change >= 0) "▲ " else "▼ ", color = SchwabColors.OnHeroGain, style = SchwabText.NumericMono)
            Text("$changeAmount ", style = SchwabText.NumericMono, color = SchwabColors.OnHeroGain)
            Text("($changePercent) today", style = SchwabText.NumericMono, color = SchwabColors.OnHeroSub)
        }
    }
}

Account Card / List Cell

import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.text.style.TextOverflow

@Composable
fun AccountCard(
    icon: androidx.compose.ui.graphics.vector.ImageVector,
    name: String, mask: String, value: String, changePct: String, change: Double, dark: Boolean,
) {
    val primary = if (dark) SchwabColors.DarkTextPrimary else SchwabColors.TextPrimary
    val secondary = if (dark) SchwabColors.DarkTextSecondary else SchwabColors.TextSecondary
    val surface2 = if (dark) SchwabColors.DarkSurface2 else SchwabColors.Surface
    val sky = if (dark) SchwabColors.Sky else SchwabColors.SkyLight

    Column {
        Row(
            Modifier.fillMaxWidth().height(66.dp).clickable {}.padding(horizontal = 20.dp),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            Box(Modifier.size(38.dp).clip(RoundedCornerShape(10.dp)).background(surface2),
                contentAlignment = Alignment.Center) {
                Icon(icon, contentDescription = null, tint = sky, modifier = Modifier.size(18.dp))
            }
            Column(Modifier.weight(1f)) {
                Text(name, style = SchwabText.AcctName, color = primary)
                Text(mask, style = SchwabText.Caption, color = secondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
            }
            Column(horizontalAlignment = Alignment.End) {
                Text(value, style = SchwabText.RowValue, color = primary)
                Text(changePct, style = SchwabText.NumericMono, color = changeColor(change, dark))
            }
        }
        Box(Modifier.fillMaxWidth().height(0.5.dp)
            .background(if (dark) SchwabColors.DarkDivider else SchwabColors.Divider))
    }
}

Trade Ticket (the centerpiece)

import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults

@Composable
fun TradeTicket(fields: List<Pair<String, String>>, onReview: () -> Unit) {
    var side by remember { mutableIntStateOf(0) }
    val haptics = LocalHapticFeedback.current

    Column(
        Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
            .background(SchwabColors.DarkSurface2).padding(16.dp),
    ) {
        Row(
            Modifier.clip(RoundedCornerShape(8.dp)).background(SchwabColors.DarkCanvas).padding(3.dp),
            horizontalArrangement = Arrangement.spacedBy(3.dp),
        ) {
            listOf("Buy", "Sell").forEachIndexed { i, label ->
                Box(
                    Modifier.weight(1f).height(36.dp).clip(RoundedCornerShape(6.dp))
                        .background(if (i == side) SchwabColors.Blue else Color.Transparent)
                        .clickable {
                            side = i
                            haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                        },
                    contentAlignment = Alignment.Center,
                ) {
                    Text(label, style = SchwabText.NumericMono,
                        color = if (i == side) SchwabColors.OnBlue else SchwabColors.DarkTextSecondary)
                }
            }
        }

        fields.forEachIndexed { idx, (label, value) ->
            Row(
                Modifier.fillMaxWidth().padding(vertical = 12.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text(label, style = SchwabText.NumericMono, color = SchwabColors.DarkTextSecondary)
                Text(value, style = SchwabText.TicketField, color = SchwabColors.DarkTextPrimary)
            }
            if (idx != fields.lastIndex) {
                Box(Modifier.fillMaxWidth().height(1.dp).background(SchwabColors.DarkDivider))
            }
        }

        Button(
            onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onReview() },
            shape = RoundedCornerShape(8.dp),
            colors = ButtonDefaults.buttonColors(containerColor = SchwabColors.Blue, contentColor = SchwabColors.OnBlue),
            modifier = Modifier.fillMaxWidth().height(52.dp).padding(top = 14.dp),
        ) { Text("Review order", style = SchwabText.Button) }
    }
}

Sticky Trade Button & Balances Tiles

@Composable
fun StickyTradeBar(onTrade: () -> Unit) {
    val haptics = LocalHapticFeedback.current
    Column(
        Modifier.fillMaxWidth()
            .background(SchwabColors.DarkCanvas.copy(alpha = 0.94f))
            .navigationBarsPadding(),
    ) {
        Box(Modifier.fillMaxWidth().height(0.5.dp).background(SchwabColors.DarkDivider))
        Button(
            onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress); onTrade() },
            shape = RoundedCornerShape(8.dp),
            colors = ButtonDefaults.buttonColors(containerColor = SchwabColors.Blue, contentColor = SchwabColors.OnBlue),
            modifier = Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 16.dp).padding(top = 12.dp),
        ) { Text("Trade", style = SchwabText.Button) }
    }
}

@Composable
fun BalancesTiles(tiles: List<Triple<String, String, Double?>>, dark: Boolean) {
    val bg = if (dark) SchwabColors.DarkSurface2 else SchwabColors.Surface
    val primary = if (dark) SchwabColors.DarkTextPrimary else SchwabColors.TextPrimary
    val secondary = if (dark) SchwabColors.DarkTextSecondary else SchwabColors.TextSecondary
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        tiles.chunked(2).forEach { row ->
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
                row.forEach { (label, value, change) ->
                    Column(
                        Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(bg)
                            .padding(horizontal = 16.dp, vertical = 14.dp),
                    ) {
                        Text(label, style = SchwabText.Caption, color = secondary)
                        Text(value, style = SchwabText.TicketField.copy(fontSize = 19.sp),
                            color = change?.let { changeColor(it, dark) } ?: primary,
                            modifier = Modifier.padding(top = 6.dp))
                    }
                }
            }
        }
    }
}

4. Navigation

Schwab has a five-tab bottom strip. On Android model it as a NavigationBar; there is no Material tint pill — active is just the sky-blue icon/label.

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*

@Composable
fun SchwabBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    NavigationBar(containerColor = SchwabColors.DarkCanvas, tonalElevation = 0.dp) {
        val items = listOf(
            "Accounts" to Icons.Filled.Home,
            "Trade"    to Icons.Filled.TrendingUp,
            "Research" to Icons.Filled.Search,
            "Messages" to Icons.Filled.Email,
            "More"     to Icons.Filled.GridView,
        )
        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 = SchwabText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = SchwabColors.Sky,
                    selectedTextColor = SchwabColors.Sky,
                    unselectedIconColor = SchwabColors.DarkTextTertiary,
                    unselectedTextColor = SchwabColors.DarkTextTertiary,
                    indicatorColor = Color.Transparent, // no Material pill
                ),
            )
        }
    }
}

The account→trade spine is a NavHost slide push: Accounts → Account detail (TabRow underline: Positions/Balances/Activity) → Quote → Trade ticket (the TradeTicket composable) → Review → confirmation.

5. Motion

Schwab motion is calm — 120–300ms ease-out, never aggressive on money.

Moment Compose recipe
Balance tick animate text color only via animateColorAsState(tween(600)), settle to primary — digits never roll
Trade Buy/Sell active pill animateOffsetAsState/Modifier.offset tween(200) between segments
Chart range morph re-path Canvas; animateFloatAsState(tween(280)) interpolating point arrays
Push navigation NavHost slide tween(300)
Sticky Trade button always present; top divider visible as content scrolls beneath
Tab switch instant content swap; icon color cross-fade tween(120)
Pull-to-refresh PullToRefreshContainer tinted SchwabColors.Blue; light haptic on trigger

Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) for tab/segmented/range change; HapticFeedbackType.LongPress for tap-Trade / Place-order; on order accepted use view.performHapticFeedback(HapticFeedbackConstants.CONFIRM).

6. Icons

Closest first-party set is androidx.compose.material:material-icons-extended. For directional gain/loss prefer Unicode triangles ▲ ▼.

Purpose SF Symbol (iOS) Material Icon (Compose)
Accounts (tab) house / house.fill Icons.Filled.Home
Trade (tab) chart.line.uptrend.xyaxis Icons.Filled.TrendingUp
Research (tab) magnifyingglass Icons.Filled.Search
Messages (tab) envelope.fill Icons.Filled.Email
More (tab) square.grid.2x2.fill Icons.Filled.GridView
Brokerage account chart.line.uptrend.xyaxis Icons.Filled.TrendingUp
Retirement account clock Icons.Filled.Schedule
Bank account creditcard Icons.Filled.CreditCard
Alerts bell bell.fill Icons.Filled.Notifications
Day change up arrowtriangle.up.fill ▲ (text) / Icons.Filled.ArrowDropUp
Day change down arrowtriangle.down.fill ▼ (text) / Icons.Filled.ArrowDropDown
Back chevron.left Icons.Filled.ArrowBack
Overflow ellipsis Icons.Filled.MoreHoriz
Disclosure chevron.right Icons.Filled.ChevronRight
Transfer arrow.left.arrow.right Icons.Filled.SwapHoriz
Documents doc.text Icons.Filled.Description
Order type slider.horizontal.3 Icons.Filled.Tune

7. Minimum SDK & Accessibility Notes

  • minSdk 24, compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge(); let the navy hero gradient draw under the status bar (use statusBarsPadding() only on non-hero screens). The sticky Trade bar uses Modifier.navigationBarsPadding(); the trade keypad uses Modifier.imePadding().
  • Schwab Blue contrast: onPrimary is OnBlue (#002233), never white — white-on-#009DDC fails WCAG at body sizes. Enforced in the color scheme above.
  • Font scaling: sp honors the user’s font scale — keep it on total value, titles, body, meta. Pin layout-sensitive text (10sp tab labels, ticket field values, table headers) via dp-derived sizes. Tabular figures must stay tabular at every scale (fontFeatureSettings = "tnum").
  • Do not rely on color alone: gain/loss must always carry +/− and ▲/▼ (components above do). Hard requirement for a finance app.
  • TalkBack: announce an account row as one node — “Roth IRA, Retirement ending 8820, 158,330 dollars 7 cents, up 0.71 percent”. Use Modifier.semantics(mergeDescendants = true); never expose color as the only state.
  • Touch targets: Material guidance is 48.dp — account rows are 66.dp (fine); give the bell button and segmented segments a 48.dp hit area via padding. Primary buttons ≥ 48.dp.
  • Contrast: #009DDC + #002233 text passes AA at 16sp bold; #18B07B/#E2564E on #0A1622 pass AA at the bold numeric weights; #5FE3B0 on the navy hero passes AA. Validate any custom pair.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, snap the Buy/Sell pill, skip the chart morph and balance-tick color animation (set final state instantly); keep the static sign/arrow.
  • Dark mode: invert via the Dark* palette — #0A1622, NOT true black; #10222E text becomes #E6EEF3; keep the navy hero gradient on dark. Shadows vanish on the navy canvas, so add a 1dp DarkDivider border to cards, the ticket, and sheets as the elevation cue. Do not enable dynamicColorScheme() — the banker-blue identity must not harmonize with wallpaper.
All mobile designs →

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