Jetpack Compose Theming for Design Systems

Compose theming uses `CompositionLocal` to implicitly pass design tokens like colors and fonts down the UI tree. This is key for building a design system where you can swap themes (like light/dark) without rewriting components.
WHY IT EXISTS: In traditional Android Views, theming was scattered across XML styles, attributes, and programmatic overrides, making consistency difficult. Jetpack Compose needed a declarative, type-safe, and hierarchical way to apply design tokens consistently across a component-based UI.
THE MENTAL MODEL: Think of Compose theming as a dependency injection system for design values. A top-level MaterialTheme composable acts as a provider, holding your design system's colors, typography, and shapes. Any composable nested inside it can "request" these values implicitly. Change the provider, and the entire UI below it updates.
HOW IT WORKS: The mechanism behind this is CompositionLocal. MaterialTheme uses CompositionLocalProvider to make its colors, typography, and shapes objects available down the composition tree. When you access MaterialTheme.colors.primary, you are reading a value from the nearest CompositionLocal for colors. This allows for powerful features like overriding parts of a theme for a specific subtree of your UI. You can also create your own custom CompositionLocal values, like for spacing or elevation, to extend your design system.
WHEN TO USE IT: Always. Theming should be a foundational part of any Compose application, not an afterthought. It's essential for supporting dark mode, enabling white-labeling or rebranding, and ensuring visual consistency across all screens and components. Building even a simple app with MaterialTheme from the start is best practice.
WHEN NOT TO USE IT: You should almost never opt out of the theming system. The only exception might be for a completely isolated, one-off UI element that must never change its appearance regardless of the app's theme, such as a legal disclaimer with specific color requirements. Even then, it's often better to model this as a special case within the theme.
ONE CANONICAL EXAMPLE: A common use case is creating a custom set of spacing values for your design system. First, you define a data class AppSpacing(val small: Dp, val medium: Dp, val large: Dp). Then, you create a CompositionLocal for it: val LocalAppSpacing = staticCompositionLocalOf { ... default values ... }. In your main theme composable, you wrap your content in CompositionLocalProvider(LocalAppSpacing provides yourSpacingDefinition) { ... }. Now, any component can access consistent spacing values with LocalAppSpacing.current.medium instead of hardcoding 8.dp.
Read the original → developer.android.com
Get five bites like this every day.
Tezvyn delivers a daily feed of 60-second tech bites with quizzes to lock in what you learn.