| name | runtime-theming |
| description | In-app theme switching with persistence and high performance. Use this when users must change theme (light/dark/brand/density) without restarting the app. |
Runtime Theming
Instructions
Runtime theming means the same process serves a different theme without restart. Make it cheap to switch and cheap to render.
1. Model the Theme as Plain Data
A theme is an immutable data object. Switching means replacing the object and letting the framework recompose.
data class AppTheme(
val colors: AppColors,
val typography: AppTypography,
val spacing: AppSpacing,
val motion: AppMotion,
val density: Density,
val isDark: Boolean,
)
No mutable fields. No callbacks inside the theme object.
2. Expose Through Platform-Idiomatic Channels
Compose: one CompositionLocal per theme axis, read in components.
val LocalAppTheme = staticCompositionLocalOf<AppTheme> { error("AppTheme not provided") }
@Composable
fun AppThemeProvider(theme: AppTheme, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalAppTheme provides theme) {
MaterialTheme(colorScheme = theme.colors.toColorScheme(),
typography = theme.typography.toTypography()) { content() }
}
}
SwiftUI: @Environment keys.
private struct DSThemeKey: EnvironmentKey { static let defaultValue: DSTheme = .light }
extension EnvironmentValues { var dsTheme: DSTheme { get { self[DSThemeKey.self] } set { self[DSThemeKey.self] = newValue } } }
RootView().environment(\.dsTheme, currentTheme)
Flutter: Theme.of(context) plus a ThemeExtension for custom tokens.
React Native: a React context with useMemo on the theme object; pair with a StyleSheet factory.
3. Persist the User Choice
Store only the choice, never the resolved theme.
enum class ThemePreference { System, Light, Dark }
class ThemeRepository(private val ds: DataStore<Preferences>) {
val preference: Flow<ThemePreference> = ds.data.map {
ThemePreference.valueOf(it[KEY] ?: ThemePreference.System.name)
}
suspend fun set(pref: ThemePreference) { ds.edit { it[KEY] = pref.name } }
companion object { private val KEY = stringPreferencesKey("theme.pref") }
}
Resolve to the concrete theme at the root:
val pref by themeRepository.preference.collectAsState(initial = ThemePreference.System)
val isDark = when (pref) {
ThemePreference.System -> isSystemInDarkTheme()
ThemePreference.Dark -> true
ThemePreference.Light -> false
}
AppThemeProvider(theme = if (isDark) DarkTheme else LightTheme) { AppRoot() }
4. Performance
- Keep the theme object referentially stable. New instance on every recomposition → everything re-reads.
- Split the theme by axis if one axis (e.g., density) changes more often than another (colors). Multiple small
CompositionLocals beat one big one.
- Do not wrap the whole tree in an animated color transition on theme change; let the OS drive animation via the native transition (Compose
Crossfade at the root is acceptable at 150–200ms).
- Never read theme fields inside tight loops (e.g., inside
items of a lazy list in a draw phase); hoist to outer composition.
5. Animating the Transition
A 150–250ms crossfade at the root is enough. Avoid per-color lerps across the tree — expensive and often janky.
Crossfade(targetState = theme, animationSpec = tween(200)) { t ->
AppThemeProvider(theme = t) { AppRoot() }
}
On iOS / SwiftUI, simply set preferredColorScheme and let UIKit drive the animation. On Flutter, AnimatedTheme is fine for light/dark; for brand switches, rebuild the root.
6. Density and Size-Class Switching
Density ("comfortable" vs "compact") and size class belong in the theme, not in per-screen state. Toggling density mid-session must not relayout the entire tree unstably — prefer a settings-gated restart if your layout is highly density-dependent.
7. Anti-Patterns
- Mutable singletons (
ThemeManager.shared.theme = .dark) — invisible to frameworks.
- Recomputing the theme object on every render.
- Switching themes by replacing the root widget (
MaterialApp) — loses navigation state.
- Persisting the entire theme blob to storage instead of the preference key.
- Per-component theme props; all theme access must flow through the provider.
Checklist