Skip to content

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:

  1. Multiple theme variants beyond light/dark: warm (teal/orange/gray), dark, and ijcp (church-specific palette).
  2. User custom themes post-MVP — users will create their own themes via a visual editor, stored as JSON in localStorage.
  3. next-themes only understands light | dark | system — extending it to support 4+ named themes requires abusing value and onValueChange callbacks, which is a bodge.

next-themes provides exactly four features, each trivial to reimplement:

Featurenext-themesOur implementation
PersistencelocalStoragelocalStorage.setItem("theme", name)
OS preferencematchMedia("prefers-color-scheme")matchMedia listener
SSR flash prevention<script> injectionIrrelevant (SPA, no SSR)
Toggle .dark on <html>attribute="class" modedocument.documentElement.setAttribute("data-theme", name)

Verdict: 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 + cycling

themes.ts responsibilities:

  • Read/write localStorage("neemias-theme")
  • Listen to matchMedia("(prefers-color-scheme: dark)") for system default
  • Apply data-theme attribute on <html> element
  • Fire a custom event themechange for consumers (sonner, future MUI bridge if needed)
  • Provide availableThemes array for UI rendering

Contract:

typescript
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:

css
[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:

  1. Collects user color choices via a UI form
  2. Serializes them as JSON → localStorage("neemias-custom-themes")
  3. Injects a <style> element with [data-theme="custom-{id}"] CSS custom properties
  4. themes.ts merges custom themes into availableThemes

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:

FileBeforeAfter
main.tsx<ThemeProvider attribute="class"><ThemeProvider> (our own context)
ThemeToggle.tsxuseTheme() from next-themesuseTheme() from ../../themes
sonner.tsxuseTheme() from next-themesuseTheme() from ../../themes

next-themes is removed from package.json.

Consequences

Positive

  • Full control over theme lifecycle — no abusing a library beyond its design
  • data-theme attribute 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 ThemeProvider context is new code to maintain
  • matchMedia listener 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)

References

Distributed under MIT License.