Dark mode support has become a baseline expectation on modern Android apps, not a feature
Theming and dark mode in Compose: lessons from production means building a color system that adapts seamlessly to user preferences while keeping your codebase maintainable and testable. We've seen many teams ship dark mode too late, or worse, ship it with inconsistencies that damage user trust. This guide covers the patterns we've validated across production e-commerce and SaaS apps.
Define light and dark color schemes
Your first step is to define a complete color palette for both light and dark themes. This isn't about picking arbitrary hex codes—it's about establishing contrast, readability, and brand consistency across every UI element.
In Compose, you typically create a Color.kt file where you name colors semantically, not by their appearance:
object LightColors {
val primary = Color(0xFF6200EA)
val primaryVariant = Color(0xFF3700B3)
val secondary = Color(0xFF03DAC6)
val background = Color(0xFFFFFFFF)
val surface = Color(0xFFFAFAFA)
val error = Color(0xFFB00020)
val onPrimary = Color(0xFFFFFFFF)
val onSecondary = Color(0xFF000000)
val onBackground = Color(0xFF1C1B1F)
val onSurface = Color(0xFF1C1B1F)
}
object DarkColors {
val primary = Color(0xFFBB86FC)
val primaryVariant = Color(0xFF3700B3)
val secondary = Color(0xFF03DAC6)
val background = Color(0xFF121212)
val surface = Color(0xFF1F1F1F)
val error = Color(0xFFCF6679)
val onPrimary = Color(0xFF000000)
val onSecondary = Color(0xFF000000)
val onBackground = Color(0xFFE1E1E1)
val onSurface = Color(0xFFE1E1E1)
}
The key insight here is the on prefix—these are the colors you use on top of a surface. If your primary color is dark purple, then onPrimary should be light so text remains readable. This semantic naming prevents mistakes where you accidentally use onSurface text on a dark background.
Next, wrap these in a data class that your theme can swap between:
data class AppColorScheme(
val primary: Color,
val primaryVariant: Color,
val secondary: Color,
val background: Color,
val surface: Color,
val error: Color,
val onPrimary: Color,
val onSecondary: Color,
val onBackground: Color,
val onSurface: Color,
)
val LightColorScheme = AppColorScheme(
primary = LightColors.primary,
primaryVariant = LightColors.primaryVariant,
secondary = LightColors.secondary,
background = LightColors.background,
surface = LightColors.surface,
error = LightColors.error,
onPrimary = LightColors.onPrimary,
onSecondary = LightColors.onSecondary,
onBackground = LightColors.onBackground,
onSurface = LightColors.onSurface,
)
val DarkColorScheme = AppColorScheme(
primary = DarkColors.primary,
primaryVariant = DarkColors.primaryVariant,
secondary = DarkColors.secondary,
background = DarkColors.background,
surface = DarkColors.surface,
error = DarkColors.error,
onPrimary = DarkColors.onPrimary,
onSecondary = DarkColors.onSecondary,
onBackground = DarkColors.onBackground,
onSurface = DarkColors.onSurface,
)
This structure is the foundation. Once it's defined, you never directly reference Color(0xFFBB86FC) in your UI code again.
Use isSystemInDarkTheme() to detect user preference
Compose gives you a built-in function to check whether the system is in dark mode. On Android 10+, this respects the system-wide dark mode setting. On older devices, it typically defaults to false.
Your theme composable should look something like this:
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
CompositionLocalProvider(
LocalColorScheme provides colorScheme,
) {
content()
}
}
val LocalColorScheme = compositionLocalOf {
LightColorScheme
}
object AppTheme {
val colors: AppColorScheme
@Composable
get() = LocalColorScheme.current
}
The critical piece here is that darkTheme: Boolean = isSystemInDarkTheme() gives you a sensible default while allowing override. In tests and previews, you can pass darkTheme = true or darkTheme = false explicitly.
Why not just use Material3's built-in MaterialTheme? In production, we've found that custom color schemes give you finer control when branding requires specific accent colors or when your color palette doesn't map cleanly to Material's defaults. That said, if Material3's palette fits your design system, absolutely use it—no need to reinvent.
Avoid hard-coded colors everywhere
This is where many teams slip up. A junior engineer hard-codes Color(0xFF333333) for text in a feature branch, it ships, and suddenly you have text that's invisible in dark mode.
The rule: never use Color() constructors in your UI code outside the color definition file.
Instead, always reference through AppTheme.colors:
@Composable
fun ProductCard(product: Product) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppTheme.colors.surface,
shape = RoundedCornerShape(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = product.name,
color = AppTheme.colors.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = product.price,
color = AppTheme.colors.primary,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
In a real codebase, we also extract these into a styling layer so they're reusable:
@Composable
fun ProductCardContent(product: Product) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = AppTheme.colors.surface,
shape = RoundedCornerShape(8.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
ProductNameText(product.name)
ProductPriceText(product.price)
}
}
}
@Composable
fun ProductNameText(name: String) {
Text(
text = name,
color = AppTheme.colors.onSurface,
style = MaterialTheme.typography.headlineSmall,
)
}
@Composable
fun ProductPriceText(price: String) {
Text(
text = price,
color = AppTheme.colors.primary,
style = MaterialTheme.typography.labelSmall,
)
}
This pattern scales. When design tells you to adjust primary to a slightly lighter shade for dark mode, you change one value in DarkColorScheme and everything updates.
Test both themes in previews
Compose previews are a game-changer for theming validation. You should preview every screen in both light and dark modes before merging to main.
@Preview(
name = "Light Mode",
showBackground = true,
backgroundColor = 0xFFFFFFFF,
)
@Composable
fun ProductCardPreviewLight() {
AppTheme(darkTheme = false) {
ProductCard(
product = Product(
id = "1",
name = "Premium Headphones",
price = "$149.99",
)
)
}
}
@Preview(
name = "Dark Mode",
showBackground = true,
backgroundColor = 0xFF121212,
)
@Composable
fun ProductCardPreviewDark() {
AppTheme(darkTheme = true) {
ProductCard(
product = Product(
id = "1",
name = "Premium Headphones",
price = "$149.99",
)
)
}
}
Running both previews side-by-side catches issues immediately. Is the text readable? Does the button stand out? Does the error message color have sufficient contrast?
For a more efficient workflow, create a preview helper that generates both at once:
@Preview(name = "Light", showBackground = true, backgroundColor = 0xFFFFFFFF)
@Preview(name = "Dark", showBackground = true, backgroundColor = 0xFF121212)
@Composable
fun ProductCardPreview() {
AppTheme {
ProductCard(
product = Product(id = "1", name = "Premium Headphones", price = "$149.99")
)
}
}
Kotlin's ability to stack multiple @Preview annotations means both variants render in the preview pane with one function.
Common mistakes
Mistake 1: Forgetting on* colors. You define light and dark backgrounds but then hardcode white text everywhere. Use onBackground, onSurface, and onPrimary consistently.
Mistake 2: Overriding isSystemInDarkTheme() without user intent. Don't force a specific theme unless the user explicitly chooses it in settings. Respect system preference.
Mistake 3: Testing only in previews. Run the app on a real device with system dark mode enabled. Previews catch most issues, but runtime behavior can differ. Check performance too—theme switching should be instantaneous.
Mistake 4: Insufficient contrast. A color might look fine on your monitor at full brightness but fail accessibility standards. Use a contrast checker tool during design review.
Mistake 5: Inconsistent accent colors across screens. If the primary button color shifts between screens, users notice. Define and use semantic colors, not screen-specific overrides.
Key takeaways
- Define a complete
AppColorSchemedata class with both light and dark variants; semantic naming (onPrimary,onSurface) prevents contrast mistakes. - Use
isSystemInDarkTheme()as the default, but allow explicit override for testing and settings. - Never hardcode colors in UI code—always reference through
AppTheme.colors. - Preview every composable in both themes before merging; stacked
@Previewannotations make this frictionless. - Test on real devices with system dark mode toggled; verify contrast and performance at runtime.
- When design requests a theme change, modify one file and trust that all screens update consistently.






Comments
Loading comments…