How to Implement Figma Design Tokens in Jetpack Compose
I was staring at color values scattered across 50+ files in my Compose codebase. Designers updated the primary brand color, and I had to search and replace through every composable. Then they updated it again next week.
I tried using Material3’s default colors first. But our Figma tokens had names like bg.surface and text.muted that didn’t map cleanly to Material3’s onSurface and onSurfaceVariant. Every design change broke the mapping.
Then I found out how big tech companies handle this. They use a three-layer token architecture.
The Token Architecture
+-------------------+ +------------------+ +------------------+| Figma Token Studio| --> | Core Colors | --> | Semantic Tokens || | | Raw palette | | Data classes |+-------------------+ +------------------+ +------------------+ | v+-------------------+ +------------------+ +------------------+| UI Components | <-- | CompositionLocal| <-- | Theme Mapping || No hardcoded col | | Provider | | Light/Dark |+-------------------+ +------------------+ +------------------+The idea is simple: Figma exports raw colors, those get mapped to semantic names, then semantic names get mapped to light/dark themes. UI code only references semantic names.
Layer 1: Core Colors
First, define raw palette colors. These never change. They’re just the raw values from Figma Token Studio.
@Immutabledata class CoreColors( val brand50: Color = Color(0xFFF0F9FF), val brand100: Color = Color(0xFFE0F2FE), val brand500: Color = Color(0xFF0EA5E9), val brand900: Color = Color(0xFF0C4A6E),
val error500: Color = Color(0xFFEF4444), val success500: Color = Color(0xFF22C55E), val warning500: Color = Color(0xFFF59E0B),
val neutral50: Color = Color(0xFFFAFAFA), val neutral100: Color = Color(0xFFF5F5F5), val neutral800: Color = Color(0xFF262626), val neutral900: Color = Color(0xFF0A0A0A),)I started by hardcoding these values. Later I automated the export from Figma Token Studio to generate this file.
Layer 2: Semantic Tokens
Next, create semantic tokens that match Figma’s naming. This is where the design-dev alignment happens.
@Immutabledata class BgTokens( val primary: Color, val secondary: Color, val surface: Color, val surfaceVariant: Color, val inverse: Color,)
@Immutabledata class TextTokens( val primary: Color, val secondary: Color, val muted: Color, val inverse: Color, val onPrimary: Color,)
@Immutabledata class IconTokens( val light: Color, val dark: Color, val primary: Color, val inverse: Color,)
@Immutabledata class DesignTokens( val bg: BgTokens, val text: TextTokens, val icon: IconTokens,)Naming matters here. I matched Figma exactly: bg.primary, text.muted, icon.dark. When a designer references a token in Figma, it maps one-to-one to code.
Layer 3: Theme Mapping
Map semantic tokens to light and dark themes using the core palette.
val lightTokens = DesignTokens( bg = BgTokens( primary = CoreColors.neutral50, secondary = CoreColors.neutral100, surface = Color.White, surfaceVariant = CoreColors.neutral100, inverse = CoreColors.neutral900, ), text = TextTokens( primary = CoreColors.neutral900, secondary = CoreColors.neutral900.copy(alpha = 0.7f), muted = CoreColors.neutral900.copy(alpha = 0.5f), inverse = Color.White, onPrimary = Color.White, ), icon = IconTokens( light = CoreColors.neutral900.copy(alpha = 0.5f), dark = Color.White.copy(alpha = 0.5f), primary = CoreColors.brand500, inverse = Color.White, ),)
val darkTokens = DesignTokens( bg = BgTokens( primary = CoreColors.neutral900, secondary = CoreColors.neutral800, surface = CoreColors.neutral900, surfaceVariant = CoreColors.neutral800, inverse = Color.White, ), text = TextTokens( primary = Color.White, secondary = Color.White.copy(alpha = 0.7f), muted = Color.White.copy(alpha = 0.5f), inverse = CoreColors.neutral900, onPrimary = Color.White, ), icon = IconTokens( light = Color.White.copy(alpha = 0.5f), dark = CoreColors.neutral900.copy(alpha = 0.5f), primary = CoreColors.brand100, inverse = CoreColors.neutral900, ),)I got this wrong at first. I tried mapping everything directly from core colors to UI components. Then designers wanted to tweak text.secondary opacity without changing core colors. This layer fixed that.
Layer 4: CompositionLocal Provider
Expose tokens via CompositionLocal so any composable can access them.
val LocalDesignTokens = staticCompositionLocalOf { DesignTokens( bg = BgTokens( primary = Color.Unspecified, secondary = Color.Unspecified, surface = Color.Unspecified, surfaceVariant = Color.Unspecified, inverse = Color.Unspecified, ), text = TextTokens( primary = Color.Unspecified, secondary = Color.Unspecified, muted = Color.Unspecified, inverse = Color.Unspecified, onPrimary = Color.Unspecified, ), icon = IconTokens( light = Color.Unspecified, dark = Color.Unspecified, primary = Color.Unspecified, inverse = Color.Unspecified, ), )}
@Composablefun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val tokens = if (darkTheme) darkTokens else lightTokens
CompositionLocalProvider(LocalDesignTokens provides tokens) { MaterialTheme( colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content ) }}
object AppTokens { val colors: DesignTokens @Composable get() = LocalDesignTokens.current}I used staticCompositionLocalOf instead of compositionLocalOf because tokens don’t change during composition. This prevents unnecessary recompositions.
Using Tokens in UI Components
Now UI code references tokens instead of hardcoded colors.
@Composablefun CardComponent( title: String, description: String, modifier: Modifier = Modifier) { Column( modifier = modifier .background(AppTokens.colors.bg.surface) .padding(16.dp) ) { Text( text = title, style = MaterialTheme.typography.titleMedium, color = AppTokens.colors.text.primary ) Text( text = description, style = MaterialTheme.typography.bodyMedium, color = AppTokens.colors.text.muted ) }}When designers change bg.surface in Figma, I only update the theme mapping. The card component stays untouched.
When to Use This Approach
This architecture works well for:
- Teams with dedicated design systems
- Apps requiring exact Figma alignment
- Products with frequent design iterations
- Large codebases with many contributors
But it’s overkill for small apps. Material3’s dynamic color system handles most use cases with less boilerplate.
// Material3 approach - fewer layers@Composablefun SimpleMaterialTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorScheme = when { darkTheme -> darkColorScheme() else -> lightColorScheme() }
MaterialTheme(colorScheme = colorScheme, content = content)}What I Learned
After implementing this in a production app, here’s what stuck:
- Three layers prevent cascade changes. Updating a core color propagates through semantic tokens without touching UI.
- Figma naming alignment matters.
bg.primaryin Figma equalsAppTokens.colors.bg.primaryin code. - Use
@Immutableon all token classes. Compose skips equality checks and recompositions. - CompositionLocal makes tokens available anywhere. No passing parameters deep through the composable tree.
- Start with Material3. Add semantic tokens only when you hit limitations.
The three-layer architecture ensures design system updates never require touching UI code. Just update the core colors or semantic token mappings, and everything else follows.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments