Skip to content

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

token-flow
+-------------------+ +------------------+ +------------------+
| 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.

CoreColors.kt
@Immutable
data 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.

SemanticTokens.kt
@Immutable
data class BgTokens(
val primary: Color,
val secondary: Color,
val surface: Color,
val surfaceVariant: Color,
val inverse: Color,
)
@Immutable
data class TextTokens(
val primary: Color,
val secondary: Color,
val muted: Color,
val inverse: Color,
val onPrimary: Color,
)
@Immutable
data class IconTokens(
val light: Color,
val dark: Color,
val primary: Color,
val inverse: Color,
)
@Immutable
data 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.

ThemeMapping.kt
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.

AppTheme.kt
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,
),
)
}
@Composable
fun 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.

CardComponent.kt
@Composable
fun 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.

SimpleMaterial3Theme.kt
// Material3 approach - fewer layers
@Composable
fun 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:

  1. Three layers prevent cascade changes. Updating a core color propagates through semantic tokens without touching UI.
  2. Figma naming alignment matters. bg.primary in Figma equals AppTokens.colors.bg.primary in code.
  3. Use @Immutable on all token classes. Compose skips equality checks and recompositions.
  4. CompositionLocal makes tokens available anywhere. No passing parameters deep through the composable tree.
  5. 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