Why Build a Design System?
When we started adopting Compose at PhonePe, every team was building their own buttons, cards, and text styles. The insurance team's button looked different from the payments team's button. Colors were hardcoded. Spacing was inconsistent.
We needed a shared design system — a single source of truth for all UI components.
The Architecture
Our design system has three layers:
Layer 1: Design Tokens
Tokens are the primitive values — colors, typography, spacing, elevation, and shapes.
object AppTokens {object Colors {
val primary = Color(0xFF5B2D8E)
val primaryVariant = Color(0xFF7B4BA8)
val surface = Color(0xFFFFFFFF)
// ... 40+ color tokens
}
object Spacing {
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
}
}
Layer 2: Theme Provider
A custom CompositionLocal-based theme that provides tokens throughout the app:
@Composablefun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) darkColors else lightColors
CompositionLocalProvider(
LocalAppColors provides colorScheme,
LocalAppTypography provides appTypography,
LocalAppSpacing provides appSpacing,
) {
MaterialTheme(content = content)
}
}
Layer 3: Components
Reusable components that consume tokens and expose clean APIs:
@Composablefun AppButton(
text: String,
onClick: () -> Unit,
variant: ButtonVariant = ButtonVariant.Primary,
size: ButtonSize = ButtonSize.Medium,
enabled: Boolean = true,
loading: Boolean = false,
) { / ... / }
Key Design Decisions
1. Composition over inheritance — Every component is a composable function, not a class. No BaseButton extending MaterialButton.
2. Slot-based APIs — For complex components, we use Compose's slot pattern:
@Composablefun AppCard(
header: @Composable () -> Unit,
content: @Composable () -> Unit,
footer: (@Composable () -> Unit)? = null,
) { / ... / }
3. Preview-driven development — Every component has @Preview annotations for all variants. This serves as living documentation.
4. Semantic naming — We use names like ButtonVariant.Destructive instead of ButtonVariant.Red. Colors can change; semantics don't.
The Component Library
After 4 months, our design system includes:
- 12 core components — Button, Card, TextField, Chip, Badge, Avatar, BottomSheet, Dialog, Snackbar, Divider, Skeleton, EmptyState
- 40+ design tokens — Colors, typography scales, spacing, elevation levels
- 3 themes — Light, Dark, and a special "festive" theme for Diwali campaigns
- 100+ preview annotations — Serving as visual documentation
Lessons Learned
- Start with tokens, not components — Get the design team to agree on primitive values first.
- Ship incrementally — We released one component at a time, not the whole system at once.
- Document with previews, not wikis — Developers trust code that compiles over docs that might be outdated.
- Performance matters at the token level — Using
staticCompositionLocalOfinstead ofcompositionLocalOfsaved us measurable recomposition overhead.
Impact
- UI consistency across 20+ screens, 5+ teams
- Development speed increased ~40% for new screens
- Design review cycles dropped from 3 rounds to 1
- Zero accessibility regressions since tokens include contrast-checked color pairs