Skip to content

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:

Theme.kt
val LocalAppTokens = staticCompositionLocalOf { LightTokens }
@Composable
fun 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.

Composition tree flow
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 A

This 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.

Two types of CompositionLocal
// Use this for values that don't change often
val LocalAppTokens = staticCompositionLocalOf<AppColorTokens> {
error("No LocalAppTokens provided")
}
// Use this for values that might change
val 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.

Tokens.kt
@Immutable
data class AppColorTokens(
val bg: BackgroundTokens,
val text: TextTokens
)
@Immutable
data class BackgroundTokens(
val primary: Color,
val secondary: Color
)
@Immutable
data class TextTokens(
val primary: Color,
val secondary: Color
)

Step 2: Create Light and Dark Token Sets

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

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

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

This is what I used initially. Create an object with a @Composable getter.

Tokens.kt
object Tokens {
val colors: AppColorTokens
@Composable
get() = LocalAppTokens.current
}

Usage in composables:

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

Theme.kt
@Composable
val appColors: AppColorTokens
get() = LocalAppTokens.current

Usage:

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

Theme.kt
// Define your tokens
val LightColors = AppColorTokens(/* ... */)
val DarkColors = AppColorTokens(/* ... */)
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
CompositionLocalProvider(LocalAppTokens provides LightColors) {
// Always provide a default, the getter decides which to use
content()
}
}
@Composable
fun currentColors(): AppColorTokens {
return if (isSystemInDarkTheme()) DarkColors else LightColors
}

Usage:

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

ExtendedTheme.kt
@Immutable
data class ExtendedColors(
val caution: Color,
val onCaution: Color
)
val LocalExtendedColors = staticCompositionLocalOf<ExtendedColors> {
error("No ExtendedColors provided")
}
@Composable
fun 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 access
val MaterialTheme.extendedColors: ExtendedColors
@Composable
get() = LocalExtendedColors.current

Usage:

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

Good naming
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

Immutable annotation
@Immutable
data 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

Good default with error
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

Wrong: static for changing value
val LocalThemeMode = staticCompositionLocalOf { ThemeMode.LIGHT }
@Composable
fun 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

Deep nesting is expensive
CompositionLocalProvider(A provides A1)
└── CompositionLocalProvider(B provides B1)
└── CompositionLocalProvider(C provides C1)
└── CompositionLocalProvider(D provides D1)
└── Your UI

Each 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.

Component with parameter-based theming
@Composable
fun 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:

Theme.kt
package com.example.app.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
// Token data classes
@Immutable
data class AppColorTokens(
val bg: BackgroundTokens,
val text: TextTokens,
val brand: BrandTokens
)
@Immutable
data class BackgroundTokens(
val primary: Color,
val secondary: Color
)
@Immutable
data class TextTokens(
val primary: Color,
val secondary: Color
)
@Immutable
data class BrandTokens(
val primary: Color,
val secondary: Color
)
// Light tokens
val 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 tokens
val 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 provider
val LocalAppColors = staticCompositionLocalOf<AppColorTokens> {
error("No AppColorTokens provided. Wrap your app with MyAppTheme")
}
// Theme wrapper
@Composable
fun 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 object
object AppTheme {
val colors: AppColorTokens
@Composable
get() = LocalAppColors.current
}

Usage in a screen:

HomeScreen.kt
package com.example.app.ui.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.app.ui.theme.AppTheme
@Composable
fun 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

  1. CompositionLocal is the official pattern for theming in Compose. It’s designed for this use case.
  2. Use staticCompositionLocalOf for theme tokens that don’t change often. Use compositionLocalOf for dynamic values.
  3. Wrap your theme with a named composable like MyAppTheme. This makes it discoverable.
  4. Create an access object or property delegate for cleaner token access.
  5. Consider the “no CompositionLocal” approach for simple theming. But you lose flexibility for nested themes.
  6. Always provide sensible defaults or error messages. Don’t use empty defaults.
  7. Use @Immutable annotations on token data classes. This helps Compose optimize.
  8. 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments