ADR-0019: Runtime Theme Switching — data-theme, Custom Hook, Custom User Themes
- Status: accepted
- Deciders: Reasonix agent + User grill session (2026-06-27)
- Date: 2026-06-27
- Supersedes: ADR-0018 §2 (next-themes choice)
- Issues: #281, #286
Context
ADR-0018 §2 chose next-themes as the theme switching mechanism for its simplicity (2KB, localStorage, prefers-color-scheme). A grill session for #281 (Runtime Theme Switching) revealed deeper requirements:
- Multiple theme variants beyond light/dark:
warm(teal/orange/gray),dark, andijcp(church-specific palette). - User custom themes post-MVP — users will create their own themes via a visual editor, stored as JSON in
localStorage. next-themesonly understandslight | dark | system— extending it to support 4+ named themes requires abusingvalueandonValueChangecallbacks, which is a bodge.
next-themes provides exactly four features, each trivial to reimplement:
| Feature | next-themes | Our implementation |
|---|---|---|
| Persistência | localStorage | localStorage.setItem("theme", name) |
| Preferência do SO | matchMedia("prefers-color-scheme") | matchMedia listener |
| SSR flash prevention | <script> injection | Irrelevante (SPA, sem SSR) |
Toggle .dark no <html> | attribute="class" mode | document.documentElement.setAttribute("data-theme", name) |
Veredict: Remove next-themes. Build our own themes.ts (~20 lines).
Decision
1. Remove next-themes, implement themes.ts
Architecture:
themes.ts ← useTheme() hook: { theme, setTheme, availableThemes, resolvedTheme }
theme.css ← [data-theme="light"], [data-theme="warm"], [data-theme="dark"], [data-theme="ijcp"]
main.tsx ← <ThemeProvider> wrapper (thin context around themes.ts)
ThemeToggle.tsx ← uses useTheme() for display + cyclingthemes.ts responsibilities:
- Read/write
localStorage("neemias-theme") - Listen to
matchMedia("(prefers-color-scheme: dark)")forsystemdefault - Apply
data-themeattribute on<html>element - Fire a custom event
themechangefor consumers (sonner, future MUI bridge if needed) - Provide
availableThemesarray for UI rendering
Contract:
type ThemeName = "light" | "warm" | "dark" | "ijcp";
interface ThemeContext {
theme: ThemeName;
setTheme: (name: ThemeName) => void;
resolvedTheme: "light" | "dark"; // for consumers that only care about light/dark
availableThemes: ThemeDefinition[];
}
interface ThemeDefinition {
name: ThemeName;
label: string; // i18n key
icon: string; // icon name (sun, sunset, moon, church)
category: "builtin" | "custom";
}2. Theme CSS: [data-theme] selector
Each theme defines its design tokens under a [data-theme="name"] CSS rule:
[data-theme="light"] {
--background: #ffffff;
--foreground: oklch(0.145 0 0);
/* ... 39 tokens */
}
[data-theme="warm"] {
--background: #fffbeb;
--primary: #b45309;
/* teal accents, orange primary, gray muted */
}
[data-theme="dark"] {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
/* ... 35 tokens */
}
[data-theme="ijcp"] {
--background: #f8f6f0;
--primary: #01696f; /* brand teal */
--secondary: #4a3728; /* wood brown */
--accent: #d4a853; /* gold */
}Current .dark class variant (@custom-variant dark (&:is(.dark *));) is kept for backward compatibility during transition. After migration, it changes to &:is([data-theme="dark"] *).
3. User custom themes (Post-MVP)
Design the data-theme attribute as the stable contract. A future theme editor:
- Collects user color choices via a UI form
- Serializes them as JSON →
localStorage("neemias-custom-themes") - Injects a
<style>element with[data-theme="custom-{id}"]CSS custom properties themes.tsmerges custom themes intoavailableThemes
This is tracked as a separate post-MVP issue. The data-theme attribute + ThemeDefinition.category field are the forward-compatible hooks.
4. Migration path from next-themes
Three files change:
| File | Before | After |
|---|---|---|
main.tsx | <ThemeProvider attribute="class"> | <ThemeProvider> (our own context) |
ThemeToggle.tsx | useTheme() from next-themes | useTheme() from ../../themes |
sonner.tsx | useTheme() from next-themes | useTheme() from ../../themes |
next-themes is removed from package.json.
Consequences
Positive
- Full control over theme lifecycle — no abusing a library beyond its design
data-themeattribute is more semantic and toolable than CSS classes- Forward-compatible with user custom themes (just add to
availableThemes) - Drops 2KB dependency
- 20 lines of our code vs 2KB of someone else's code
Negative
- Migration touches 3 files (all previously touched in DS-002/DS-003)
- Custom
ThemeProvidercontext is new code to maintain matchMedialistener cleanup must be handled correctly to avoid memory leaks
Neutral
- Tailwind v4 dark variant syntax unchanged
- shadcn/ui components unaffected (they consume CSS variables, not
next-themes) - Current ThemeToggle UI preserved (just the hook source changes)