Skip to content
Back to Build Tutorials

Theme Bridge

Bridge design-system themes (light/dark/brand) to code with CSS variables and a theme provider.

Why

Dark mode is no longer optional — users expect it, and OS-level preferences make it the default for many. A theme bridge uses CSS custom properties (variables) so the same component renders correctly in any theme by swapping a set of variable values, not by conditional classes. This is far more maintainable than a 'dark:' class on every element: the component doesn't know about themes, it just uses var(--surface-bg). The bridge also handles brand theming (white-label SaaS), where the same app serves multiple brands with different color palettes — this is a config change, not a code change, when done with CSS variables.

When

When you need ≥2 themes (light/dark, or multi-brand). Takes 2-3 h. Skip for single-theme internal tools.

How

1. Define semantic tokens as CSS custom properties on :root
   - --surface-bg, --surface-fg, --border-color, --accent-primary
   - These are the only colors components reference — never raw hex

2. Define theme overrides in [data-theme="dark"] or .dark selectors
   - .dark { --surface-bg: #0a0a0a; --surface-fg: #fafafa; }
   - Each theme is just a set of variable overrides, no component changes

3. Add a ThemeProvider that sets data-theme on <html> and persists to localStorage
   - Read prefers-color-scheme on first visit; let user override via a toggle
   - Apply the theme before first paint (inline script in <head>) to avoid FOUC

4. In Tailwind, map the CSS variables to utility classes
   - colors: { surface: { bg: "var(--surface-bg)", fg: "var(--surface-fg)" } }
   - Then bg-surface-bg works in all themes automatically

5. For multi-brand: load brand tokens from a config (DB or JSON) at runtime
   - Set the CSS variables on :root from JS — document.documentElement.style.setProperty('--accent-primary', brand.color)

Example

/* globals.css — semantic tokens + dark overrides */
:root {
  --surface-bg: #ffffff; --surface-fg: #0a0a0a; --accent-primary: #6366f1;
}
.dark {
  --surface-bg: #0a0a0a; --surface-fg: #fafafa; --accent-primary: #818cf8;
}

/* tailwind.config.ts */
export default { theme: { extend: { colors: {
  surface: { bg: "var(--surface-bg)", fg: "var(--surface-fg)" },
  accent: { primary: "var(--accent-primary)" },
} } } };

// ThemeProvider (no FOUC — inline script in <head>)
export function ThemeProvider({ children }) {
  useEffect(() => {
    const saved = localStorage.getItem("theme") || "system";
    const theme = saved === "system" ? matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" : saved;
    document.documentElement.classList.toggle("dark", theme === "dark");
  }, []);
  return <>{children}</>;
}

Common Problems

  • ⚠Using Tailwind's dark: variant on every element instead of CSS variables, so adding a third theme requires touching every component.
  • ⚠Flash of unstyled content (FOUC) on load because the theme is applied in a React effect instead of an inline script.
  • ⚠Not respecting the OS preference on first visit, so dark-mode users get a white flash before the effect runs.
  • ⚠Hardcoding brand colors in components instead of variables, so white-labeling requires a full fork of the codebase.

Best Practices

  • ✓Use semantic token names (surface-bg, not white) so the token's meaning survives a theme change.
  • ✓Apply the theme with an inline script in <head> before React hydrates — this eliminates FOUC entirely.
  • ✓Test every screen in both light and dark mode in Storybook — visual regression catches contrast issues.
  • ✓For multi-brand, load brand tokens server-side and inline them in the HTML so there's no flash of the default brand.