Implementing Dark Mode in Android with Jetpack Compose: The Complete Guide
Dark mode has become a standard expectation for modern Android applications. Users appreciate the reduced eye strain during nighttime usage, and it's become a crucial part of your app's user experience. In this comprehensive guide, we'll explore how to implement dark mode in Android using Jetpack Compose, covering everything from system integration to manual user preferences.
Why Dark Mode Matters
Before we dive into implementation, let's understand why dark mode is important:
- User Preference: Studies show 73% of users prefer dark mode for nighttime usage
- Battery Efficiency: OLED screens consume less power displaying dark pixels
- Accessibility: Reduced eye strain for extended usage periods
- Modern Expectation: Users expect dark mode as a baseline feature
Understanding isSystemInDarkTheme()
The isSystemInDarkTheme() function is your gateway to detecting whether the system is currently using dark mode. This composable reads the system's UI mode and returns a boolean indicating dark mode status.
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) {
darkColorScheme()
} else {
lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
This approach automatically adapts to system settings. However, many users want manual control—that's where DataStore comes in.
Manual Toggle with DataStore
DataStore is Android's modern replacement for SharedPreferences, offering a type-safe, coroutine-friendly way to persist user preferences.
Step 1: Add Dependencies
dependencies {
implementation "androidx.datastore:datastore-preferences:1.0.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1"
}
Step 2: Create a ThemeRepository
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class ThemeRepository(private val dataStore: DataStore<Preferences>) {
companion object {
val DARK_MODE_KEY = booleanPreferencesKey("dark_mode")
}
val darkModeEnabled: Flow<Boolean> = dataStore.data
.map { preferences ->
preferences[DARK_MODE_KEY] ?: isSystemInDarkTheme()
}
suspend fun setDarkMode(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[DARK_MODE_KEY] = enabled
}
}
}
Step 3: Create DataStore Instance
In your MainActivity or application setup:
import androidx.datastore.preferences.preferencesDataStore
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "app_theme")
// In your activity or composable setup
val themeRepository = ThemeRepository(context.dataStore)
Step 4: Integrate with Compose Theme
@Composable
fun MyAppTheme(
themeRepository: ThemeRepository,
content: @Composable () -> Unit
) {
val darkModeEnabled by themeRepository.darkModeEnabled.collectAsState(
initial = isSystemInDarkTheme()
)
val colorScheme = if (darkModeEnabled) {
darkColorScheme()
} else {
lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
Dynamic Color: Material 3 Theming
Android 12 introduced Dynamic Color, which extracts colors from the user's wallpaper. Material 3 fully supports this through dynamicDarkColorScheme() and dynamicLightColorScheme().
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@Composable
fun MyAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) {
dynamicDarkColorScheme(LocalContext.current)
} else {
dynamicLightColorScheme(LocalContext.current)
}
}
darkTheme -> darkColorScheme()
else -> lightColorScheme()
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
This ensures your app respects both system-wide dark mode and the device's dynamic color preferences.
Testing Dark Mode
Testing dark mode in the preview is straightforward with Compose:
@Preview(
name = "Dark Mode",
uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL,
showBackground = true
)
@Preview(
name = "Light Mode",
uiMode = UI_MODE_NIGHT_NO or UI_MODE_TYPE_NORMAL,
showBackground = true
)
@Composable
fun MyScreenPreview() {
MyAppTheme {
MyScreen()
}
}
For programmatic testing:
class ThemeToggleTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testDarkModeToggle() {
composeTestRule.setContent {
MyAppTheme {
Button(onClick = { /* toggle */ }) {
Text("Toggle Dark Mode")
}
}
}
composeTestRule.onNodeWithText("Toggle Dark Mode").performClick()
// Assert theme changed
}
}
Color Selection Tips for Dark Backgrounds
Choosing appropriate colors for dark mode requires different thinking than light mode:
Do's
- Use high contrast: Text should be #FFFFFF on dark backgrounds, not gray on gray
- Reduce saturation: Highly saturated colors feel aggressive on dark backgrounds. Use desaturated, cooler tones
- Test with reduced brightness: View your app at lower screen brightness to catch contrast issues
- Use proper Material colors: Material 3 provides tested color schemes—use them as reference
- Account for OLED: On OLED displays, pure blacks (#000000) are more comfortable than near-blacks
Don'ts
- Avoid pure white text on pure black: This causes eye strain. Use #E8E8E8 on #121212
- Don't use bright neons: Colors like #00FF00 or #FF00FF are painful in dark mode
- Don't ignore contrast requirements: WCAG AA requires 4.5:1 contrast for text
- Don't forget about accent colors: They should be less saturated in dark mode
Example Color Palette
val DarkColorScheme = darkColorScheme(
primary = Color(0xFFBB86FC), // Purple, desaturated
secondary = Color(0xFF03DAC6), // Teal
tertiary = Color(0xFF03DAC6),
background = Color(0xFF121212), // Near-black, not pure black
surface = Color(0xFF1E1E1E), // Slightly lighter for elevation
onBackground = Color(0xFFE8E8E8), // Slightly off-white
onSurface = Color(0xFFE8E8E8),
error = Color(0xFFCF6679) // Red, desaturated
)
val LightColorScheme = lightColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
tertiary = Color(0xFF1F6DEF),
background = Color(0xFFFFFFFF),
surface = Color(0xFFF5F5F5),
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
error = Color(0xFFB3261E)
)
Complete Implementation Example
Here's a complete, production-ready implementation:
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
object DarkModeManager {
@Composable
fun withTheme(
themeRepository: ThemeRepository,
content: @Composable () -> Unit
) {
val darkModeEnabled by themeRepository.darkModeEnabled.collectAsState(
initial = isSystemInDarkTheme()
)
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkModeEnabled) {
dynamicDarkColorScheme(LocalContext.current)
} else {
dynamicLightColorScheme(LocalContext.current)
}
}
darkModeEnabled -> darkColorScheme(
primary = Color(0xFFBB86FC),
secondary = Color(0xFF03DAC6),
tertiary = Color(0xFF03DAC6),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
onBackground = Color(0xFFE8E8E8),
onSurface = Color(0xFFE8E8E8),
error = Color(0xFFCF6679)
)
else -> lightColorScheme(
primary = Color(0xFF6200EE),
secondary = Color(0xFF03DAC6),
tertiary = Color(0xFF1F6DEF),
background = Color(0xFFFFFFFF),
surface = Color(0xFFF5F5F5),
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
error = Color(0xFFB3261E)
)
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
}
Best Practices Summary
-
Start with system settings: Use
isSystemInDarkTheme()as the default - Allow manual override: Use DataStore to let users override system settings
- Use Material 3 colors: They're tested and follow WCAG standards
- Test extensively: Preview both light and dark modes with actual content
- Consider OLED displays: Use proper near-blacks, not pure black
- Monitor user feedback: Dark mode preferences vary—gather feedback
- Implement Dynamic Color: On Android 12+, respect wallpaper-based colors
Conclusion
Implementing dark mode in Jetpack Compose is straightforward when you understand the key components: system detection with isSystemInDarkTheme(), user preferences via DataStore, and Material 3's powerful color system. By following these practices and testing thoroughly, you'll create an app that respects user preferences and provides a comfortable experience in any lighting condition.
Dark mode isn't just a trend—it's an expectation. Your users will thank you for implementing it properly.
Ready to Build More?
All 8 templates support both light and dark themes out of the box. Explore complete, production-ready Android app templates that handle dark mode, accessibility, and modern best practices automatically.
Top comments (0)