Back to all posts

Building a Design System in Jetpack Compose From Scratch

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:

@Composable

fun 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:

@Composable

fun 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:

@Composable

fun 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 staticCompositionLocalOf instead of compositionLocalOf saved 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