CompositionLocal Theming in Jetpack Compose: The Complete Guide
I was implementing a Figma design system in Jetpack Compose. I needed a custom theme with specific colors, spacing, and typography that didn’t match Material3 defaults. I tried this:
val LocalAppTokens = staticCompositionLocalOf { LightTokens }
@Composablefun DailyDoTheme(darkTheme: Boolean, content: @Composable () -> Unit) { CompositionLocalProvider( LocalAppTokens provides if (darkTheme) DarkTokens else LightTokens ) { MaterialTheme(content = content) }}It worked. But I wasn’t sure if this was the right approach. I found a Reddit discussion asking the same question. The community had useful insights. Let me share what I learned.
Understanding CompositionLocal for Theming
CompositionLocal is Compose’s way of passing data through the composition tree without explicitly passing it down through every composable. Think of it like a context that travels with your UI.
CompositionLocalProvider(value = A) └── ChildA() // can read A └── Grandchild() // can still read A └── ChildB() // can read A └── CompositionLocalProvider(value = B) // overrides A └── GrandchildOfB() // reads B, not AThis is perfect for theming. You set your theme tokens once at the root. Every composable can access them. You don’t need to pass colors through parameters.
staticCompositionLocalOf vs compositionLocalOf
There are two ways to create a CompositionLocal. The difference is performance.
// Use this for values that don't change oftenval LocalAppTokens = staticCompositionLocalOf<AppColorTokens> { error("No LocalAppTokens provided")}
// Use this for values that might changeval LocalDynamicToken = compositionLocalOf<Color> { Color.Red}staticCompositionLocalOf is faster but causes more recomposition. When the value changes, the entire content below recomposes. Use it for theme tokens that only change when dark mode toggles.
compositionLocalOf is smarter. Only composables that read the value will recompose. Use it for values that change frequently.
Building a Complete Theme System
Step 1: Define Token Data Classes
First, create data classes for your design tokens. Mark them with @Immutable for better optimization.
@Immutabledata class AppColorTokens( val bg: BackgroundTokens, val text: TextTokens)
@Immutabledata class BackgroundTokens( val primary: Color, val secondary: Color)
@Immutabledata class TextTokens( val primary: Color, val secondary: Color)Step 2: Create Light and Dark Token Sets
val LightTokens = AppColorTokens( bg = BackgroundTokens( primary = Color(0xFFFFFFFF), secondary = Color(0xFFF5F5F5) ), text = TextTokens( primary = Color(0xFF1C1B1F), secondary = Color(0xFF49454F) ))
val DarkTokens = AppColorTokens( bg = BackgroundTokens( primary = Color(0xFF1C1B1F), secondary = Color(0xFF49454F) ), text = TextTokens( primary = Color(0xFFF5F5F5), secondary = Color(0xFFCAC4D0) ))Step 3: Create the CompositionLocal Provider
val LocalAppTokens = staticCompositionLocalOf<AppColorTokens> { error("No LocalAppTokens provided. Wrap your app with MyAppTheme")}The error message helps if you forget to provide the value. You’ll see it at runtime instead of a null reference.
Step 4: Build the Theme Wrapper
@Composablefun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val tokens = if (darkTheme) DarkTokens else LightTokens
CompositionLocalProvider(LocalAppTokens provides tokens) { MaterialTheme( colors = mapToMaterialColors(tokens), typography = MaterialTheme.typography, content = content ) }}
private fun mapToMaterialColors(tokens: AppColorTokens): Colors { // Map your custom tokens to Material colors return lightColors( primary = tokens.text.primary, background = tokens.bg.primary, // ... more mappings )}Note the naming. Use your app or company name in the theme function. This makes it discoverable. The Reddit community confirmed this pattern.
Accessing Theme Tokens in UI
Pattern 1: Object-Based Access (Recommended)
This is what I used initially. Create an object with a @Composable getter.
object Tokens { val colors: AppColorTokens @Composable get() = LocalAppTokens.current}Usage in composables:
@Composablefun HomeScreen() { Column( modifier = Modifier.background(Tokens.colors.bg.primary) ) { Text( text = "Welcome", color = Tokens.colors.text.primary ) Text( text = "Subtitle", color = Tokens.colors.text.secondary ) }}This gives clean code with Tokens.colors access. IDE autocomplete helps you discover available tokens.
Pattern 2: Property Delegate Access
You can use a property delegate for even cleaner syntax.
@Composableval appColors: AppColorTokens get() = LocalAppTokens.currentUsage:
@Composablefun HomeScreen() { Column( modifier = Modifier.background(appColors.bg.primary) ) { Text(text = "Welcome", color = appColors.text.primary) }}Pattern 3: Direct CompositionLocal Access
You can access LocalAppTokens.current directly. I don’t recommend this. It’s less discoverable and harder to refactor.
The “No CompositionLocal” Alternative
I found an interesting insight on Reddit. For simple theming, you don’t always need CompositionLocal.
// Define your tokensval LightColors = AppColorTokens(/* ... */)val DarkColors = AppColorTokens(/* ... */)
@Composablefun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { CompositionLocalProvider(LocalAppTokens provides LightColors) { // Always provide a default, the getter decides which to use content() }}
@Composablefun currentColors(): AppColorTokens { return if (isSystemInDarkTheme()) DarkColors else LightColors}Usage:
@Composablefun HomeScreen() { Column( modifier = Modifier.background(currentColors().bg.primary) ) { Text(text = "Welcome", color = currentColors().text.primary) }}This works. But there’s a trade-off. You get less control. You can’t override the theme for a subtree. CompositionLocal gives you that flexibility. Use this pattern only if you’re certain you’ll never need nested themes.
Integration with Material3
Material3 has its own theming system. You can extend it with custom tokens.
@Immutabledata class ExtendedColors( val caution: Color, val onCaution: Color)
val LocalExtendedColors = staticCompositionLocalOf<ExtendedColors> { error("No ExtendedColors provided")}
@Composablefun ExtendedTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val extendedColors = ExtendedColors( caution = Color(0xFFFFCC02), onCaution = Color(0xFF2C2D30) )
CompositionLocalProvider(LocalExtendedColors provides extendedColors) { MaterialTheme( colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content ) }}
// Add an extension to MaterialTheme for easier accessval MaterialTheme.extendedColors: ExtendedColors @Composable get() = LocalExtendedColors.currentUsage:
@Composablefun WarningCard(message: String) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.extendedColors.caution ) ) { Text( text = message, color = MaterialTheme.extendedColors.onCaution ) }}This pattern keeps Material3’s standard theme while adding your custom tokens.
Best Practices & Common Pitfalls
DO: Prefix with Local for Discoverability
val LocalAppTokens = staticCompositionLocalOf { /* ... */ }val LocalExtendedColors = staticCompositionLocalOf { /* ... */ }The Local prefix is a convention. It makes these values easy to find in autocomplete.
DO: Use @Immutable on Token Data Classes
@Immutabledata class AppColorTokens( val bg: BackgroundTokens, val text: TextTokens)This tells Compose that the object won’t change. Compose can optimize better.
DO: Provide Sensible Defaults
val LocalAppTokens = staticCompositionLocalOf<AppColorTokens> { error("No LocalAppTokens provided. Wrap your app with MyAppTheme")}Don’t use empty defaults like LightTokens. The error helps you find the problem faster.
DON’T: Use staticCompositionLocalOf for Values That Change Often
val LocalThemeMode = staticCompositionLocalOf { ThemeMode.LIGHT }
@Composablefun App() { var mode by remember { mutableStateOf(ThemeMode.LIGHT) } // This causes full recomposition every time mode changes! CompositionLocalProvider(LocalThemeMode provides mode) { // ... entire app recomposes }}Use compositionLocalOf instead. Only the reading composables will update.
DON’T: Nest Themes Too Deeply
CompositionLocalProvider(A provides A1) └── CompositionLocalProvider(B provides B1) └── CompositionLocalProvider(C provides C1) └── CompositionLocalProvider(D provides D1) └── Your UIEach level adds overhead. Keep your theme structure flat. Combine related tokens into single objects.
Performance Considerations
CompositionLocal isn’t free. It has a cost. For most apps, this cost is negligible. But for complex UIs with many nested providers, it adds up.
When a staticCompositionLocalOf value changes, the entire subtree recomposes. This is expensive. Use compositionLocalOf for values that change often.
If you’re building a component library, consider passing tokens as parameters instead of using CompositionLocal. This gives consumers more control.
@Composablefun CustomButton( text: String, colors: ButtonColors, onClick: () -> Unit) { Button( onClick = onClick, colors = colors ) { Text(text) }}This component is more reusable. Consumers can provide any colors they want.
Complete Working Example
Here’s a full theme implementation you can adapt:
package com.example.app.ui.theme
import androidx.compose.material3.MaterialThemeimport androidx.compose.material3.darkColorSchemeimport androidx.compose.material3.lightColorSchemeimport androidx.compose.runtime.Composableimport androidx.compose.runtime.CompositionLocalProviderimport androidx.compose.runtime.compositionLocalOfimport androidx.compose.runtime.staticCompositionLocalOfimport androidx.compose.ui.graphics.Color
// Token data classes@Immutabledata class AppColorTokens( val bg: BackgroundTokens, val text: TextTokens, val brand: BrandTokens)
@Immutabledata class BackgroundTokens( val primary: Color, val secondary: Color)
@Immutabledata class TextTokens( val primary: Color, val secondary: Color)
@Immutabledata class BrandTokens( val primary: Color, val secondary: Color)
// Light tokensval LightColorTokens = AppColorTokens( bg = BackgroundTokens( primary = Color(0xFFFFFFFF), secondary = Color(0xFFF5F5F5) ), text = TextTokens( primary = Color(0xFF1C1B1F), secondary = Color(0xFF49454F) ), brand = BrandTokens( primary = Color(0xFF6750A4), secondary = Color(0xFF9F87C5) ))
// Dark tokensval DarkColorTokens = AppColorTokens( bg = BackgroundTokens( primary = Color(0xFF1C1B1F), secondary = Color(0xFF49454F) ), text = TextTokens( primary = Color(0xFFF5F5F5), secondary = Color(0xFFCAC4D0) ), brand = BrandTokens( primary = Color(0xFFD0BCFF), secondary = Color(0xFF9F87C5) ))
// CompositionLocal providerval LocalAppColors = staticCompositionLocalOf<AppColorTokens> { error("No AppColorTokens provided. Wrap your app with MyAppTheme")}
// Theme wrapper@Composablefun MyAppTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { val colorTokens = if (darkTheme) DarkColorTokens else LightColorTokens
CompositionLocalProvider(LocalAppColors provides colorTokens) { MaterialTheme( colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content ) }}
// Access objectobject AppTheme { val colors: AppColorTokens @Composable get() = LocalAppColors.current}Usage in a screen:
package com.example.app.ui.home
import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.Columnimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport com.example.app.ui.theme.AppTheme
@Composablefun HomeScreen() { Column( modifier = Modifier .fillMaxSize() .background(AppTheme.colors.bg.primary) ) { Text( text = "Welcome", color = AppTheme.colors.text.primary ) Text( text = "This is a subtitle", color = AppTheme.colors.text.secondary ) Text( text = "Brand Primary", color = AppTheme.colors.brand.primary ) }}Key Takeaways
- CompositionLocal is the official pattern for theming in Compose. It’s designed for this use case.
- Use
staticCompositionLocalOffor theme tokens that don’t change often. UsecompositionLocalOffor dynamic values. - Wrap your theme with a named composable like
MyAppTheme. This makes it discoverable. - Create an access object or property delegate for cleaner token access.
- Consider the “no CompositionLocal” approach for simple theming. But you lose flexibility for nested themes.
- Always provide sensible defaults or error messages. Don’t use empty defaults.
- Use
@Immutableannotations on token data classes. This helps Compose optimize. - Don’t nest themes too deeply. Keep your theme structure flat.
Theming in Jetpack Compose doesn’t have to be complicated. CompositionLocal gives you a clean way to manage design tokens. Start with the patterns shown here. Adapt them to your design system. Your UI will be consistent and easy to maintain.
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:
- 👨💻 Android Developer - CompositionLocal
- 👨💻 Android Developer - Custom Design Systems
- 👨💻 Reddit Discussion - r/androiddev
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments