Should Design Tokens Be Separate from MaterialTheme.colorScheme in Jetpack Compose?
I was implementing a Figma design with Jetpack Compose and Material3. I had my design tokens extracted from the design file, all nice and organized. But then I hit a wall.
My design had a nice purple brand color. I set up my tokens separately from Material’s color system. When I added a simple Button without specifying colors, it showed up in the default Material purple instead of my brand purple.
What went wrong?
The Problem: Default Components Use Material Colors
I tried keeping my tokens completely separate. Here’s what I did first:
object DesignTokens { val brandPrimary = Color(0xFF6200EE) val brandOnPrimary = Color(0xFFFFFFFF) val error = Color(0xFFB00020) val background = Color(0xFFFBFBFB) val surface = Color(0xFFFBFBFB)}Then I used these tokens directly in my composables:
Box( modifier = Modifier.background(DesignTokens.background)) { Text("Hello", color = DesignTokens.brandPrimary)}This worked fine for my own composables. But then I used a Material Button without specifying colors:
Button(onClick = { }) { Text("Click me")}The button showed up in the wrong color. Why?
Because Material3 components fall back to MaterialTheme.colorScheme when you don’t specify colors. I wasn’t mapping my tokens to Material’s color scheme. So the button used the default Material colors instead of my brand colors.
My First Attempt: Mapping Everything
I tried mapping my tokens to Material’s color slots. I created a function that took my tokens and returned a ColorScheme:
fun createColorScheme(): ColorScheme { return ColorScheme( primary = DesignTokens.brandPrimary, onPrimary = DesignTokens.brandOnPrimary, error = DesignTokens.error, background = DesignTokens.background, surface = DesignTokens.surface, )}But this approach had a problem. It only worked for light mode. I needed to handle dark mode too. And I wanted my tokens to be the source of truth.
The Solution: Adapter Layer Pattern
After some trial and error, I found a pattern that works. Treat Material3 as an adapter layer, not your primary color system.
First, define a base class for your tokens:
data class BaseColors( val brandPrimary: Color, val brandOnPrimary: Color, val brandPrimaryContainer: Color, val brandOnPrimaryContainer: Color, val error: Color, val onError: Color, val errorContainer: Color, val onErrorContainer: Color, val background: Color, val onBackground: Color, val surface: Color, val onSurface: Color,)Then create instances for light and dark themes:
object LightTokens : BaseColors( brandPrimary = Color(0xFF6200EE), brandOnPrimary = Color(0xFFFFFFFF), brandPrimaryContainer = Color(0xFFBB86FC), brandOnPrimaryContainer = Color(0xFF3700B3), error = Color(0xFFB00020), onError = Color(0xFFFFFFFF), errorContainer = Color(0xFFFCD8DC), onErrorContainer = Color(0xFF410002), background = Color(0xFFFBFBFB), onBackground = Color(0xFF1C1B1F), surface = Color(0xFFFBFBFB), onSurface = Color(0xFF1C1B1F),)
object DarkTokens : BaseColors( brandPrimary = Color(0xFFBB86FC), brandOnPrimary = Color(0xFF21005D), brandPrimaryContainer = Color(0xFF3700B3), brandOnPrimaryContainer = Color(0xFFE1BFFF), error = Color(0xFFCF6679), onError = Color(0xFF690005), errorContainer = Color(0xFF8C1D18), onErrorContainer = Color(0xFFFCD8DC), background = Color(0xFF1C1B1F), onBackground = Color(0xFFE6E1E5), surface = Color(0xFF1C1B1F), onSurface = Color(0xFFE6E1E5),)Now create a mapping function that turns your tokens into a Material ColorScheme:
fun mapToMaterialColorScheme(tokens: BaseColors): ColorScheme { return ColorScheme( primary = tokens.brandPrimary, onPrimary = tokens.brandOnPrimary, primaryContainer = tokens.brandPrimaryContainer, onPrimaryContainer = tokens.brandOnPrimaryContainer,
secondary = tokens.brandPrimary, onSecondary = tokens.brandOnPrimary, secondaryContainer = tokens.brandPrimaryContainer, onSecondaryContainer = tokens.brandOnPrimaryContainer,
tertiary = tokens.brandPrimary, onTertiary = tokens.brandOnPrimary, tertiaryContainer = tokens.brandPrimaryContainer, onTertiaryContainer = tokens.brandOnPrimaryContainer,
error = tokens.error, onError = tokens.onError, errorContainer = tokens.errorContainer, onErrorContainer = tokens.onErrorContainer,
background = tokens.background, onBackground = tokens.onBackground, surface = tokens.surface, onSurface = tokens.onSurface, surfaceVariant = tokens.surface, onSurfaceVariant = tokens.onSurface,
inverseSurface = tokens.onSurface, inverseOnSurface = tokens.surface,
outline = tokens.surface.copy(alpha = 0.12f), outlineVariant = tokens.surface.copy(alpha = 0.08f), scrim = Color.Black.copy(alpha = 0.32f), )}Here’s how the architecture looks:
┌─────────────────────────────────────────────────────────────┐│ Design Tokens (Source of Truth) ││ - Brand colors ││ - Neutral scales ││ - Semantic colors (error, warning, success, etc.) │└─────────────────────┬───────────────────────────────────────┘ │ │ Mapping Layer │┌─────────────────────▼───────────────────────────────────────┐│ MaterialTheme Configuration Layer ││ lightColorScheme { ││ primary = tokens.brandPrimary ││ onPrimary = tokens.brandOnPrimary ││ surface = tokens.surfaceBase ││ onSurface = tokens.surfaceOnBase ││ // ... mapping semantic tokens to Material slots ││ } │└─────────────────────┬───────────────────────────────────────┘ │┌─────────────────────▼───────────────────────────────────────┐│ MaterialTheme Composition ││ MaterialTheme( ││ colorScheme = if (isSystemInDarkTheme()) darkScheme else lightScheme, ││ // ... typography, shapes, etc. ││ ) { ││ // Your app content ││ } │└─────────────────────────────────────────────────────────────┘Finally, use it in your AppTheme composable:
@Composablefun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorScheme = if (darkTheme) { mapToMaterialColorScheme(DarkTokens) } else { mapToMaterialColorScheme(LightTokens) }
MaterialTheme( colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content )}Now when I use a Button without specifying colors:
Button(onClick = { }) { Text("Click me")}It uses my brand primary color. The mapping layer ensures Material components use my design tokens.
What About Custom Colors?
Your design might have colors that don’t fit in Material’s system. You can keep these separate using CompositionLocal:
data class ExtendedColors( val accent: Color, val success: Color, val info: Color, val warning: Color,)
val LocalExtendedColors = staticCompositionLocalOf { ExtendedColors( accent = Color.Unspecified, success = Color.Unspecified, info = Color.Unspecified, warning = Color.Unspecified, )}@Composablefun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), extendedColors: ExtendedColors = AppExtendedColors, content: @Composable () -> Unit) { val colorScheme = if (darkTheme) { mapToMaterialColorScheme(DarkTokens) } else { mapToMaterialColorScheme(LightTokens) }
CompositionLocalProvider(LocalExtendedColors provides extendedColors) { MaterialTheme( colorScheme = colorScheme, typography = AppTypography, shapes = AppShapes, content = content ) }}Then use them in your composables:
@Composablefun SuccessMessage(message: String) { val extendedColors = LocalExtendedColors.current
Box( modifier = Modifier .background(extendedColors.success) .padding(16.dp) ) { Text( text = message, color = MaterialTheme.colorScheme.onPrimary ) }}What I Learned
Here’s what this approach gives you:
-
Default Material components use your brand colors because they’re mapped to Material’s color slots.
-
Your design tokens are still the source of truth. Material just consumes them.
-
Update your tokens in one place, and the entire Material theme updates automatically.
-
Keep custom colors separate while still benefiting from Material’s component system.
-
If you move away from Material3 later, your token system stays intact.
Pitfalls I Found
Don’t use tokens directly everywhere
Box( modifier = Modifier .background(LightTokens.surface)) { Text("Hello", color = LightTokens.onSurface)}If you forget to pass colors to a Material component, it falls back to default Material colors. The mapping layer prevents this.
Don’t hardcode Material colors
Box( modifier = Modifier .background(MaterialTheme.colorScheme.primary))This ties you to Material. It makes it harder to migrate or test. Use semantic colors from your theme instead.
Don’t skip mapping all Material color slots
ColorScheme( primary = tokens.brandPrimary, onPrimary = tokens.brandOnPrimary, // Missing error, background, surface, etc.)Components using unmapped slots fall back to default Material colors.
When to Use Tokens Directly
Sometimes you want to use tokens directly, not through MaterialTheme. Do this for:
-
Component-specific colors that have no Material equivalent.
-
Brand-specific elements that need to stay consistent even if the theme changes.
-
Custom graphics and illustrations that use your brand palette.
@Composablefun BrandLogo() { Canvas(modifier = Modifier.size(48.dp)) { drawCircle( color = LightTokens.brandPrimary, radius = size.minDimension / 2 ) }}Testing the Mapping
Make sure your mapping layer works correctly. Write tests:
@Testfun `light color scheme maps tokens correctly`() { val scheme = mapToMaterialColorScheme(LightTokens)
assertEquals(LightTokens.brandPrimary, scheme.primary) assertEquals(LightTokens.error, scheme.error) assertEquals(LightTokens.background, scheme.background)}
@Testfun `dark color scheme maps tokens correctly`() { val scheme = mapToMaterialColorScheme(DarkTokens)
assertEquals(DarkTokens.brandPrimary, scheme.primary) assertEquals(DarkTokens.error, scheme.error) assertEquals(DarkTokens.background, scheme.background)}Summary
Don’t keep your design tokens completely separate from MaterialTheme.colorScheme. Instead:
-
Keep design tokens as your single source of truth.
-
Map semantic tokens (brand, surface, background, error) to Material’s color slots.
-
Treat Material3 as an adapter that consumes your token system.
-
Store custom colors in an
ExtendedColorsobject. -
Use tokens directly for brand-specific elements.
This ensures default Material components use your brand colors while keeping your design system clean and separate from the UI framework.
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