Runtime theming
Skill almasumdev/awesome-mobile-design-system-agent-skills/.github/skills/theming/runtime-theming
Agent skills for building and maintaining mobile design systems, tokens, and component libraries.
npx -y skills add almasumdev/awesome-mobile-design-system-agent-skills --skill runtime-themingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
In-app theme switching with persistence and high performance. Use this when users must change theme (light/dark/brand/density) without restarting the app.
SKILL.md
4.8 KB, as published. Nobody here has run it
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
Crossfadeat the root is acceptable at 150–200ms). - Never read theme fields inside tight loops (e.g., inside
itemsof 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
- The theme is an immutable data object, provided via the platform's canonical channel.
- User preference is persisted; concrete theme is resolved at the root from preference + system.
- Theme object identity is stable across unrelated recompositions.
- Switching theme does not reset navigation stack or lose input focus.
- A crossfade of 150–250ms at the root handles the transition; no per-color lerp.
- No mutable global theme state.