Dynamic Color and Dark Theme in Jetpack Compose

Quick answer: Use
isSystemInDarkTheme()as the default dark-mode choice, use dynamic color only on Android 12 (API 31) and newer, and always keep static light and darkColorSchemefallbacks. Dynamic color is optional: enable it when wallpaper personalization supports the product, and disable it when your brand palette must stay fixed.
Dynamic color and dark theme solve different problems. Dark theme chooses a light or dark appearance. Dynamic color chooses the palette for that appearance from the user’s wallpaper. Keeping those decisions separate makes a Compose theme predictable on every supported Android version.
The official Material 3 Compose guidance documents that dynamic schemes are available on Android 12 and later. On older devices, an app needs its own light and dark color schemes.
Decide when dynamic color belongs in your product
Dynamic color can make a utility, settings, media, or personal app feel at home on Android. It is less suitable when a product’s identity depends on a precise, always-visible brand color.
| Situation | Recommended choice | Why |
|---|---|---|
| Android 12+ app where personalization is welcome | Enable dynamic color by default | The palette can harmonize with the user’s device. |
| Android 11 or older | Use static light or dark schemes | The dynamic-scheme APIs require API 31. |
| Brand-critical checkout, white-label, or tightly governed UI | Disable dynamic color | A controlled palette is more important than personalization. |
| Screenshots, previews, and visual regression checks | Disable dynamic color | Static colors make review repeatable. |
This is a product setting, not a rendering detail. If users can change it, store that preference in app state and pass the result into the theme from the app root. Do not keep a durable user preference in remember inside a composable.
Use one explicit theme policy
Keep static schemes in Color.kt and the selection policy in Theme.kt. The example below is a complete minimal policy; replace the empty static schemes with your product’s semantic color roles.
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val StaticLightColors = lightColorScheme()
private val StaticDarkColors = darkColorScheme()
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
useDynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
useDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context)
else dynamicLightColorScheme(context)
}
darkTheme -> StaticDarkColors
else -> StaticLightColors
}
MaterialTheme(
colorScheme = colorScheme,
content = content,
)
}dynamicLightColorScheme() and dynamicDarkColorScheme() are annotated for API 31 in the Material 3 API reference. The SDK check is therefore not optional. It protects older devices and gives them a deliberate fallback rather than an unavailable feature.
At the activity boundary, wrap the app once:
setContent {
AppTheme {
MyApp()
}
}For the full structure of colors, typography, and shapes around this policy, start with MaterialTheme in Jetpack Compose.
Dark theme and dynamic color are independent axes
The following mental model avoids a common mistake: dynamic color does not turn dark mode on. It supplies either a dynamic light scheme or a dynamic dark scheme after your app has chosen the appearance.
darkTheme = false + useDynamicColor = false -> static light scheme
darkTheme = true + useDynamicColor = false -> static dark scheme
darkTheme = false + useDynamicColor = true -> dynamic light scheme on API 31+
darkTheme = true + useDynamicColor = true -> dynamic dark scheme on API 31+isSystemInDarkTheme() is a practical default because it follows the device setting. A product can expose a Light / Dark / System preference, but that preference should be resolved before AppTheme receives its darkTheme parameter. The theme itself should simply render the selected state.
Keep custom UI semantic in every scheme
Material 3 components automatically read the active ColorScheme. Custom UI needs the same discipline. Choose a background role and pair it with the matching on* foreground role instead of checking darkTheme inside each component.
@Composable
fun StatusCard(message: String) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
) {
Text(
text = message,
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.bodyLarge,
)
}
}This pairing matters more with dynamic color because the wallpaper can produce palettes you did not design by hand. Avoid hard-coded feature colors such as Color(0xFF...); put fixed brand values in the static schemes, then consume semantic roles in features.
Make previews deterministic, then test personalization
Static previews are the fast way to catch unreadable text, missing surface colors, and unintended hard-coded values. Turn dynamic color off in previews so every developer sees the same result.
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Light", showBackground = true)
@Composable
private fun StatusCardLightPreview() {
AppTheme(darkTheme = false, useDynamicColor = false) {
StatusCard(message = "Backup completed")
}
}
@Preview(name = "Dark", showBackground = true)
@Composable
private fun StatusCardDarkPreview() {
AppTheme(darkTheme = true, useDynamicColor = false) {
StatusCard(message = "Backup completed")
}
}Then run a separate manual check on an Android 12+ emulator or device with dynamic color enabled. Change the wallpaper if possible and inspect important states: selected items, disabled controls, errors, sheets, dialogs, and text over containers. The Jetpack Compose Preview guide shows how to extend visual checks to realistic data, device sizes, and font scales.
Common mistakes
Treating dynamic color as a replacement for app theming
Dynamic color supplies a ColorScheme; it does not define your typography, shapes, spacing, or custom component decisions. Keep a complete MaterialTheme and let dynamic color be one selectable palette source.
Forgetting the pre-Android-12 path
Calling a dynamic scheme without the API check excludes a portion of Android devices from the intended behavior. Always provide both static schemes, even if the modern path is dynamic.
Mixing the wrong foreground and background roles
onPrimary is intended for content on primary, not as a universal text color. Use the matching role pair—or let Material components choose their defaults—to preserve readable contrast across light, dark, and dynamic palettes.
Trusting only one wallpaper or one theme
A single light screenshot cannot reveal issues in a dark dynamic scheme. Review both static themes first, then validate dynamic color with more than one wallpaper-derived palette on an API 31+ device.
A release checklist for color modes
- Static light and dark schemes express the product’s core brand and semantic roles.
- The dynamic-color branch is gated by
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S. - Important custom UI uses
ColorSchemeroles and matchingon*colors. - Previews cover light and dark with dynamic color disabled.
- A real Android 12+ device or emulator has been checked with dynamic color enabled.
- User-selected appearance and dynamic-color preferences are owned outside the composable theme.
FAQ
Should every Compose app enable dynamic color?
No. It is a good default for products that benefit from personalization, but a fixed palette is often the better choice for strict branding, white-label apps, or screens with contractual color requirements.
Does dynamic color work on Android 11?
No. Material 3’s dynamic color schemes are available from Android 12 (API 31). Your app should use its static light or dark scheme on Android 11 and older.
Should I build separate dark-mode UI branches?
Usually no. Put light and dark values in your color schemes and consume semantic roles through MaterialTheme.colorScheme. Create a separate layout only when the information architecture—not merely the palette—genuinely needs to change.
Next step
With a theme policy in place, the next useful skill is choosing the right semantic role for each custom surface, action, and status. That is the focus of the upcoming ColorScheme article; until then, use the MaterialTheme guide as the reference for defining your app’s static schemes.