coderef

Robinhood

Robinhood is a brokerage rendered as a clean tech consumer product. The canvas is white, the chart is the hero, and the entire interface revolves around a single recurring motif: a giant line graph showing your portfolio value, rendered in…
DownloadRaw
Robinhood

Build with the Robinhood look.

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

Palette

At a glance

Theme
light
Typefaces
Capsule Sans Text, Capsule Sans Display, Inter
Sections
8
Length
4,094 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Robinhood

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

Robinhood (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports Robinhood’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 (the stark white canvas with true-black text, direction-bound green/orange, the full-bleed portfolio chart with its draggable scrubber, tabular numerals everywhere) while making everything idiomatic Android — a Canvas-drawn line graph with pointerInput scrubbing instead of Swift Charts, NavigationBar instead of a UITabBar, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for fetched company/ticker logos.

1. Color Tokens

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

object RobinhoodColors {
    // Canvas & Surfaces (Light)
    val CanvasLight     = Color(0xFFFFFFFF)
    val SurfaceGray     = Color(0xFFF7F7F7)
    val SurfaceGray2    = Color(0xFFEFEFEF)
    val DividerLight    = Color(0xFFE6E6E6)

    // Text (Light) — true black, no warm grays
    val TextPrimary     = Color(0xFF000000)
    val TextSecondary   = Color(0xFF5C6166)
    val TextTertiary    = Color(0xFF9B9EA3)
    val TextMuted       = Color(0xFFC2C5CA)

    // Brand — color is bound to direction
    val Green           = Color(0xFF00C805) // up
    val GreenPressed    = Color(0xFF00A904)
    val GreenDim        = Color(0xFF21CE99) // heritage
    val GreenBg         = Color(0xFFE6F9E0)
    val Red             = Color(0xFFFF5000) // down — a saturated ORANGE, not red
    val RedBg           = Color(0xFFFFEDE5)

    // Semantic
    val ErrorTrue       = Color(0xFFE62232) // a TRUE red — hard errors only, distinct from loss orange
    val Warning         = Color(0xFFFFB800)
    val Info            = Color(0xFF1D6FF2)

    // Crypto accents
    val Bitcoin         = Color(0xFFF7931A)
    val Ethereum        = Color(0xFF627EEA)

    // Dark mode — true black; the green line glows electric
    val DarkCanvas      = Color(0xFF000000)
    val DarkSurface1    = Color(0xFF181B1F)
    val DarkSurface2    = Color(0xFF23272D)
    val DarkDivider     = Color(0xFF2D3138)
    val DarkTextPrimary = Color(0xFFFFFFFF)
    val DarkTextSecondary = Color(0xFFA4A8AD)
}

Robinhood’s default is a stark light canvas — clean tech-product, deliberate counterpoint to legacy brokerages. Wire a Material 3 lightColorScheme/darkColorScheme. Do not map green/orange onto primary/error — they are direction signals, not theme roles. Keep primary as black (the universal Trade CTA color) and the brand greens/orange as explicit tokens so no stock component can accidentally use them.

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

private val RobinhoodLight = lightColorScheme(
    primary        = RobinhoodColors.TextPrimary,   // black — the universal Trade CTA
    onPrimary      = Color.White,
    background     = RobinhoodColors.CanvasLight,
    onBackground   = RobinhoodColors.TextPrimary,
    surface        = RobinhoodColors.CanvasLight,
    onSurface      = RobinhoodColors.TextPrimary,
    surfaceVariant = RobinhoodColors.SurfaceGray,
    outline        = RobinhoodColors.DividerLight,
    error          = RobinhoodColors.ErrorTrue,     // the TRUE red, not the loss orange
)

private val RobinhoodDark = darkColorScheme(
    primary        = RobinhoodColors.DarkTextPrimary, // white — Trade CTA on black
    onPrimary      = RobinhoodColors.DarkCanvas,
    background     = RobinhoodColors.DarkCanvas,
    onBackground   = RobinhoodColors.DarkTextPrimary,
    surface        = RobinhoodColors.DarkSurface1,
    onSurface      = RobinhoodColors.DarkTextPrimary,
    surfaceVariant = RobinhoodColors.DarkSurface2,
    outline        = RobinhoodColors.DarkDivider,
    error          = RobinhoodColors.ErrorTrue,
)

@Composable
fun RobinhoodTheme(
    dark: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) = MaterialTheme(
    colorScheme = if (dark) RobinhoodDark else RobinhoodLight,
    typography  = RobinhoodTypography,
    content     = content,
)

2. Typography

Capsule Sans (drawn by XYZ Type for Robinhood) is proprietary. Drop the TTFs in res/font/ and build two families — CapsuleSansText for body, CapsuleSansDisplay for hero values. Fall back to the system font; on the fallback, always apply FontFeatureSettings("tnum") (tabular numerals) — this is the single most important typography rule on a brokerage app: columns of prices must align as the user scrolls.

// ui/theme/RobinhoodType.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 CapsuleText = FontFamily(
    Font(R.font.capsule_sans_text_regular,  FontWeight.Normal),   // 400
    Font(R.font.capsule_sans_text_medium,   FontWeight.Medium),   // 500
    Font(R.font.capsule_sans_text_semibold, FontWeight.SemiBold), // 600
    Font(R.font.capsule_sans_text_bold,     FontWeight.Bold),     // 700
)
val CapsuleDisplay = FontFamily(
    Font(R.font.capsule_sans_display_bold,  FontWeight.Bold),     // hero values 40sp+
)

// Tabular numerals — non-negotiable on every price / percent / share count
private const val TNUM = "tnum"

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object RobinhoodText {
    val PortfolioHero  = TextStyle(CapsuleDisplay, fontWeight = FontWeight.Bold,     fontSize = 40.sp, lineHeight = 40.sp, letterSpacing = (-0.5).sp, fontFeatureSettings = TNUM)
    val DayChange      = TextStyle(CapsuleText,    fontWeight = FontWeight.Medium,   fontSize = 16.sp, lineHeight = 19.sp, fontFeatureSettings = TNUM)
    val ScreenTitle    = TextStyle(CapsuleText,    fontWeight = FontWeight.Bold,     fontSize = 22.sp, lineHeight = 25.sp, letterSpacing = (-0.2).sp)
    val SectionHeader  = TextStyle(CapsuleText,    fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
    val PositionTitle  = TextStyle(CapsuleText,    fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
    val Ticker         = TextStyle(CapsuleText,    fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 17.sp, letterSpacing = 0.3.sp)
    val PositionValue  = TextStyle(CapsuleText,    fontWeight = FontWeight.Medium,   fontSize = 17.sp, lineHeight = 20.sp, fontFeatureSettings = TNUM)
    val PositionChange = TextStyle(CapsuleText,    fontWeight = FontWeight.Medium,   fontSize = 13.sp, lineHeight = 17.sp, fontFeatureSettings = TNUM)
    val ChartAxis      = TextStyle(CapsuleText,    fontWeight = FontWeight.Medium,   fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp, fontFeatureSettings = TNUM)
    val Body           = TextStyle(CapsuleText,    fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 21.sp)
    val BodySmall      = TextStyle(CapsuleText,    fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 18.sp)
    val ButtonPrimary  = TextStyle(CapsuleText,    fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
    val RangeChip      = TextStyle(CapsuleText,    fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 0.3.sp)
    val TabLabel       = TextStyle(CapsuleText,    fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
    val AllCaps        = TextStyle(CapsuleText,    fontWeight = FontWeight.Bold,     fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
}

val RobinhoodTypography = Typography(
    displayMedium = RobinhoodText.PortfolioHero,
    headlineSmall = RobinhoodText.ScreenTitle,
    titleMedium   = RobinhoodText.PositionTitle,
    bodyMedium    = RobinhoodText.Body,
    labelSmall    = RobinhoodText.TabLabel,
)

3. Signature Components

Portfolio Hero (top of Investing tab)

BUYING POWER all-caps label → the giant tabular $ value in Capsule Display → the day-change line in green (up) or orange (down) — no pill chrome, just colored text. The value morphs in <100ms during chart scrubbing.

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.text.NumberFormat
import java.util.Locale

private val usd = NumberFormat.getCurrencyInstance(Locale.US)

@Composable
fun PortfolioHero(
    portfolioValue: Double,
    dayChange: Double,
    dayChangePct: Double,
    modifier: Modifier = Modifier,
) {
    val up = dayChange >= 0
    val changeColor = if (up) RobinhoodColors.Green else RobinhoodColors.Red
    val sign = if (up) "+" else "−"

    Column(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 24.dp, vertical = 16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        Text("BUYING POWER", style = RobinhoodText.AllCaps, color = RobinhoodColors.TextSecondary)
        // Value morphs during scrub
        AnimatedContent(
            targetState = portfolioValue,
            transitionSpec = { (fadeIn(tween(90)) togetherWith fadeOut(tween(90))) },
            label = "portfolioValue",
        ) { v ->
            Text(usd.format(v), style = RobinhoodText.PortfolioHero, color = RobinhoodColors.TextPrimary)
        }
        Text(
            "$sign${usd.format(kotlin.math.abs(dayChange))} ($sign${"%.2f".format(dayChangePct * 100)}%) Today",
            style = RobinhoodText.DayChange,
            color = changeColor,
        )
    }
}

Portfolio Chart with Scrubber (THE Robinhood interaction)

Swift Charts has no Compose equivalent that supports the live scrubber idiomatically — draw it with Canvas and capture the drag with pointerInput. A 2.dp stroke in the day’s color, no fill below the line, full-bleed. Dragging snaps the bubble to the nearest data point and drives the hero value (hoist scrubValue to the screen and feed it back into PortfolioHero).

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType

@Composable
fun PortfolioChart(
    points: List<Float>,           // y-values, evenly spaced in x
    isUp: Boolean,
    onScrub: (index: Int?) -> Unit, // null on release → hero snaps back to current
    modifier: Modifier = Modifier,
) {
    val lineColor = if (isUp) RobinhoodColors.Green else RobinhoodColors.Red
    var activeIndex by remember { mutableStateOf<Int?>(null) }
    val haptics = LocalHapticFeedback.current
    val strokePx = with(androidx.compose.ui.platform.LocalDensity.current) { 2.dp.toPx() }

    Canvas(
        modifier = modifier
            .fillMaxWidth()
            .height(220.dp) // 180 on small phones, 240 on Max — derive from windowSizeClass
            .pointerInput(points) {
                detectDragGestures(
                    onDragStart = { off ->
                        val idx = ((off.x / size.width) * (points.size - 1)).toInt().coerceIn(points.indices)
                        activeIndex = idx; onScrub(idx)
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                    },
                    onDrag = { change, _ ->
                        val idx = ((change.position.x / size.width) * (points.size - 1)).toInt().coerceIn(points.indices)
                        if (idx != activeIndex) {
                            activeIndex = idx; onScrub(idx) // instant — no smoothing
                        }
                    },
                    onDragEnd = { activeIndex = null; onScrub(null) },
                    onDragCancel = { activeIndex = null; onScrub(null) },
                )
            },
    ) {
        if (points.size < 2) return@Canvas
        val maxV = points.max(); val minV = points.min()
        val range = (maxV - minV).takeIf { it != 0f } ?: 1f
        fun px(i: Int) = size.width * i / (points.size - 1)
        fun py(v: Float) = size.height * (1f - (v - minV) / range)

        val path = Path().apply {
            moveTo(px(0), py(points[0]))
            for (i in 1 until points.size) lineTo(px(i), py(points[i]))
        }
        drawPath(path, color = lineColor, style = Stroke(width = strokePx)) // no fill below

        activeIndex?.let { i ->
            // thin vertical rule + solid black point bubble
            drawLine(RobinhoodColors.TextPrimary, Offset(px(i), 0f), Offset(px(i), size.height), strokeWidth = 1f)
            drawCircle(RobinhoodColors.TextPrimary, radius = 8f, center = Offset(px(i), py(points[i])))
        }
    }
}

Chart Range Chips

Text-only, no container. Active flips to the chart-line color with a 2.dp underline.

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box

@Composable
fun ChartRangeChips(
    selected: String,
    isUp: Boolean,
    onSelect: (String) -> Unit,
    modifier: Modifier = Modifier,
) {
    val active = if (isUp) RobinhoodColors.Green else RobinhoodColors.Red
    val ranges = listOf("1D", "1W", "1M", "3M", "YTD", "1Y", "5Y", "ALL")
    val haptics = LocalHapticFeedback.current

    Row(
        modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        ranges.forEach { r ->
            val on = r == selected
            Column(
                horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
                modifier = Modifier
                    .weight(1f)
                    .heightIn(min = 44.dp) // 44dp min touch even though visual is smaller
                    .clickableNoRipple {
                        onSelect(r)
                        haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                    },
            ) {
                Text(
                    r,
                    style = RobinhoodText.RangeChip,
                    color = if (on) active else RobinhoodColors.TextSecondary,
                    modifier = Modifier.padding(vertical = 6.dp),
                )
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height(2.dp)
                        .background(if (on) active else Color.Transparent)
                )
            }
        }
    }
}

import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.composed
fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier = composed {
    clickable(remember { MutableInteractionSource() }, indication = null, onClick = onClick)
}

Position Row

56.dp tall. 32.dp rounded-square logo, company name above tracked ticker + share count, market value + day-change (tabular) right-aligned, 0.5.dp bottom divider.

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.Alignment
import coil.compose.AsyncImage

@Composable
fun PositionRow(
    ticker: String,
    company: String,
    shares: Double,
    marketValue: Double,
    dayChange: Double,
    dayChangePct: Double,
    logoUrl: String?,
    modifier: Modifier = Modifier,
) {
    val up = dayChange >= 0
    val changeColor = if (up) RobinhoodColors.Green else RobinhoodColors.Red
    val sign = if (up) "+" else "−"

    Row(
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp)
            .padding(horizontal = 16.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        if (logoUrl != null) {
            AsyncImage(
                model = logoUrl,
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)),
            )
        } else {
            Box(
                Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(RobinhoodColors.SurfaceGray),
                contentAlignment = Alignment.Center,
            ) { Text(ticker.take(1), style = RobinhoodText.PositionTitle, color = RobinhoodColors.TextPrimary) }
        }

        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(company, style = RobinhoodText.PositionTitle, color = RobinhoodColors.TextPrimary, maxLines = 1)
            Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                Text(ticker, style = RobinhoodText.Ticker, color = RobinhoodColors.TextSecondary)
                Text("· ${shares.formatShares()} shares", style = RobinhoodText.BodySmall, color = RobinhoodColors.TextSecondary)
            }
        }

        Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {
            Text(usd.format(marketValue), style = RobinhoodText.PositionValue, color = RobinhoodColors.TextPrimary)
            Text(
                "$sign${usd.format(kotlin.math.abs(dayChange))} ($sign${"%.2f".format(dayChangePct * 100)}%)",
                style = RobinhoodText.PositionChange,
                color = changeColor,
            )
        }
    }
    Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(RobinhoodColors.DividerLight))
}

private fun Double.formatShares(): String =
    if (this % 1.0 == 0.0) toInt().toString() else "%.4f".format(this).trimEnd('0').trimEnd('.')

Trade Button (sticky footer) & Buy/Sell Pair

The universal Trade CTA is black — green/orange are reserved for Buy/Sell inside the trade flow.

@Composable
fun TradeButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
    val haptics = LocalHapticFeedback.current
    Box(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .height(48.dp)
            .clip(RoundedCornerShape(8.dp))
            .background(RobinhoodColors.TextPrimary) // black, not green
            .clickableNoRipple {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~impact medium
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) { Text(label, style = RobinhoodText.ButtonPrimary, color = Color.White) }
}

@Composable
fun BuySellButtons(onBuy: () -> Unit, onSell: () -> Unit, modifier: Modifier = Modifier) {
    Row(modifier.fillMaxWidth().padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
        Box(
            Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(8.dp))
                .background(RobinhoodColors.Red).clickableNoRipple(onSell),
            contentAlignment = Alignment.Center,
        ) { Text("Sell", style = RobinhoodText.ButtonPrimary, color = Color.White) }
        Box(
            Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(8.dp))
                .background(RobinhoodColors.Green).clickableNoRipple(onBuy),
            contentAlignment = Alignment.Center,
        ) { Text("Buy", style = RobinhoodText.ButtonPrimary, color = Color.White) }
    }
}

4. The Portfolio Chart Scrubber — wiring the live hero update

§3 draws the chart; this is the data flow that makes the scrubber Robinhood’s signature. The screen owns the scrub index; while dragging, the hero shows the scrubbed value and on release snaps back to current. The numeric morph runs in <100ms via AnimatedContent.

@Composable
fun InvestingScreen(
    series: List<Pair<Long, Float>>, // (epochMillis, portfolioValue)
    currentValue: Double,
    dayChange: Double,
    dayChangePct: Double,
) {
    var scrubIndex by remember { mutableStateOf<Int?>(null) }
    val isUp = dayChange >= 0
    val shownValue = scrubIndex?.let { series[it].second.toDouble() } ?: currentValue

    Column {
        PortfolioHero(
            portfolioValue = shownValue,                // updates live during scrub
            dayChange = dayChange,
            dayChangePct = dayChangePct,
        )
        PortfolioChart(
            points = series.map { it.second },
            isUp = isUp,
            onScrub = { scrubIndex = it },              // null on release → snaps back
        )
        ChartRangeChips(selected = "1D", isUp = isUp, onSelect = { /* reload series */ })
    }
}

Reduce-motion: skip the AnimatedContent morph during scrubbing — swap the value instantly (drag is already 60fps with no smoothing).

5. Bottom Navigation (Tab Bar)

Five labeled destinations in a Material 3 NavigationBar: Investing, Search, Crypto, Cash, Account. Active tint is black (not green — green is the direction signal). Solid surface, 0.5.dp top divider, no blur. Below it, a single sticky black Trade footer on the stock-detail screen.

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

@Composable
fun RobinhoodBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val haptics = LocalHapticFeedback.current
    NavigationBar(
        containerColor = RobinhoodColors.CanvasLight,
        tonalElevation = 0.dp,
    ) {
        val tabs = listOf(
            "Investing" to Icons.Filled.ShowChart,
            "Search"    to Icons.Filled.Search,
            "Crypto"    to Icons.Filled.CurrencyBitcoin,
            "Cash"      to Icons.Filled.AccountBalanceWallet,
            "Account"   to Icons.Filled.AccountCircle,
        )
        tabs.forEachIndexed { i, (label, icon) ->
            NavigationBarItem(
                selected = selected == i,
                onClick = {
                    onSelect(i)
                    haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // selection
                },
                icon = { Icon(icon, label, modifier = Modifier.size(24.dp)) },
                label = { Text(label, style = RobinhoodText.TabLabel) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor   = RobinhoodColors.TextPrimary, // black, not green
                    selectedTextColor   = RobinhoodColors.TextPrimary,
                    unselectedIconColor = RobinhoodColors.TextTertiary,
                    unselectedTextColor = RobinhoodColors.TextTertiary,
                    indicatorColor      = Color.Transparent,
                ),
            )
        }
    }
}

6. Motion

Moment Compose recipe
Chart scrubber drag pointerInput + detectDragGestures, snap to nearest index, no smoothing (instant)
Portfolio value update AnimatedContent cross-fade tween(90) — <100ms numeric morph
Buy/Sell confirm animateFloatAsState 1 → 0.97 → 1 spring(dampingRatio = 0.7f) + HapticFeedbackType.LongPress (impact medium)
Order placed green particle burst (Canvas + Animatable over ~800ms) + HapticFeedbackType.Confirm (success)
Tab switch instant icon/label color change + HapticFeedbackType.TextHandleMove (selection)
Range chip switch animateColorAsState text + underline cross-fade spring(stiffness = 700f)
Position row tap animateColorAsState bg white → SurfaceGray over 150ms before navigating
// Buy/Sell press scale
@Composable
fun rememberPressScale(interaction: MutableInteractionSource): Float {
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(
        if (pressed) 0.97f else 1f,
        spring(dampingRatio = 0.7f, stiffness = 500f),
        label = "pressScale",
    )
    return scale
}

Haptics: prefer LocalHapticFeedback. The scrub tick and tab switch use HapticFeedbackType.TextHandleMove (selection-like); Buy/Sell confirm uses LongPress (impact medium); order success uses HapticFeedbackType.Confirm. For finer control, Vibrator VibrationEffect.createOneShot(10, ...) approximates iOS’s medium impact.

7. Icons

Robinhood uses custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The portfolio chart is hand-drawn (§3, not an icon). Custom marks (success confetti, order states) ship as vector drawables via ImageVector.vectorResource(...).

Purpose SF Symbol (iOS) Material Icon (Compose)
Investing (tab) chart.line.uptrend.xyaxis Icons.Filled.ShowChart
Search (tab) magnifyingglass Icons.Filled.Search
Crypto (tab) bitcoinsign.circle Icons.Filled.CurrencyBitcoin
Cash (tab) wallet.pass Icons.Filled.AccountBalanceWallet
Account (tab) person.crop.circle Icons.Filled.AccountCircle
Search (in field) magnifyingglass Icons.Filled.Search
Set alert bell / bell.fill Icons.Filled.NotificationsNone / Icons.Filled.Notifications
Add to list plus.circle Icons.Filled.AddCircleOutline
Back chevron.left Icons.AutoMirrored.Filled.ArrowBack
Share square.and.arrow.up Icons.Filled.IosShare
Order placed (success) checkmark.circle.fill Icons.Filled.CheckCircle
Order failed xmark.circle.fill Icons.Filled.Cancel

8. Minimum SDK & Accessibility Notes

  • minSdk 24. compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+. The chart is a hand-drawn Canvas (no extra dependency) so it works at minSdk with no Swift-Charts equivalent needed.
  • Edge-to-edge: call enableEdgeToEdge(). The light canvas wants dark-content system bars (white wants light-content on dark mode). The chart goes edge-to-edge; apply Scaffold insets only to the hero, lists, and the sticky Trade footer (clears gesture nav).
  • Tabular numerals are critical: every Text rendering a price, percent, or share count must use fontFeatureSettings = "tnum" (baked into the RobinhoodText styles). On the fallback font this guarantees columns align as the user scrolls a watchlist — the brand-defining rule.
  • Font scaling: sp honors user scale on body, secondary text, list rows. The portfolio hero (40sp) scales but clamp at 56sp to preserve layout; chart axis labels (11sp), range chips, and tab labels are fixed (layout-sensitive) — wrap in a fixed-fontScale LocalDensity. Position values honor scaling but clamp at 22sp.
  • TalkBack: the hero should announce “Buying power $24,847.93, up $847.93, up 3.52 percent today” via one merged semantics. Position rows: “Apple Inc., A A P L, 4 shares, market value $1,247.92, up $24.13, up 1.97 percent today”. The chart needs a contentDescription summarizing the trend plus an accessible “scrub to time” affordance (the drag gesture alone is not TalkBack-reachable).
  • Color-blind: green/orange is the primary direction signal — always pair with the +/− sign prefix (already in the components) so it is never color-only. When isHighTextContrastEnabled/differentiate-without-color is set, prefix an up/down arrow on the change text.
  • Touch targets: range chips are visually small but must keep a 44.dp min hit (heightIn(min = 44.dp), already applied); Trade button is 48.dp; tab items 56.dp.
  • Contrast: #000000 on #FFFFFF is maximum; #5C6166 on white passes AA at 14sp+; #9B9EA3 (inactive tab labels) passes AA only at 18sp+ — keep the 10sp tab label at #000000 once selected. Dark mode: green #00C805 and orange #FF5000 stay identical (they read electric on #000000).
  • Dynamic color: do not enable Material You dynamicColorScheme() — the stark white/true-black canvas and the direction-bound green/orange are the brand. A wallpaper-tinted primary would break the “color = direction” contract and the clean tech-product aesthetic.
All mobile designs →

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