Multi-Brand Theming and Dark Mode
Why Theming Is an Architecture Decision
Most teams bolt on dark mode as an afterthought — they sprinkle a few @media (prefers-color-scheme: dark) queries and call it done. Then the CEO says "we just acquired a company and need to rebrand their product to our colors." Suddenly your entire component library needs a second set of hardcoded values and everything breaks.
Theming is not a feature you add later. It is an architectural decision you make on day one. When done right, switching from light to dark, from Brand A to Brand B, or from high-contrast to low-contrast is a single token swap — zero component changes.
Think of theming like a theater production. The script (your components) stays the same. The costumes (your tokens) change between shows. A good script never says "wear the blue dress" — it says "wear the costume labeled HERO_OUTFIT." The wardrobe department (your theme layer) decides what HERO_OUTFIT looks like for the matinee vs. the evening show. Your components should work exactly the same way.
CSS Custom Properties for Runtime Theming
The foundation of runtime theming is CSS custom properties. Unlike SCSS variables that compile away at build time, CSS custom properties exist at runtime and can be changed dynamically.
/* Base theme (light) */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-border: #e5e7eb;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
}
/* Dark theme override */
[data-theme="dark"] {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
--color-primary: #818cf8;
--color-primary-hover: #a5b4fc;
}
Components never reference color values directly — they only reference the custom property names:
.card {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
When data-theme="dark" is set on a parent element, every component using these tokens instantly switches. No JavaScript re-renders. No class toggling on individual elements. Pure CSS cascade.
Dark Mode Done Right
Getting dark mode right requires solving three problems: detecting the user's system preference, allowing manual override, and persisting the choice. Most tutorials only handle the first one.
The Three-State Problem
Users fall into three categories:
- System preference, no override — they want whatever their OS is set to
- Explicit light — they set your site to light regardless of OS
- Explicit dark — they set your site to dark regardless of OS
You need three states, not two:
type ThemePreference = 'system' | 'light' | 'dark';
Avoiding the Flash of Wrong Theme
The biggest dark mode bug is FOUC (Flash of Unstyled Content) — the page loads in light mode, then flickers to dark. This happens because React hydration runs after the first paint.
The fix: a blocking script in the <head> that runs before any rendering:
<head>
<script>
(function() {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored === 'dark' || stored === 'light'
? stored
: (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
This script is intentionally vanilla JS, synchronous, and render-blocking. It runs before the browser paints anything, so there is no flash. In Next.js, you put this in a <Script strategy="beforeInteractive"> or directly in your root layout.
Do not put the theme detection logic in a React useEffect. Effects run after paint, which means the user sees light mode for a split second before dark mode kicks in. The blocking script approach is the only way to prevent FOUC. Yes, it adds a few milliseconds to the critical rendering path — that is an acceptable trade-off for avoiding a jarring visual flash on every page load.
The Theme Provider Pattern
function useTheme() {
const [preference, setPreference] = useState<ThemePreference>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as ThemePreference) || 'system';
});
useEffect(() => {
const resolved = preference === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: preference;
document.documentElement.setAttribute('data-theme', resolved);
localStorage.setItem('theme', preference);
}, [preference]);
useEffect(() => {
if (preference !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
document.documentElement.setAttribute(
'data-theme',
e.matches ? 'dark' : 'light'
);
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [preference]);
return { preference, setPreference };
}
This hook handles all three states: when the user picks "system," it tracks the OS-level prefers-color-scheme media query and updates in real time when the user changes their system theme.
Multi-Brand Theming
Multi-brand theming is the same concept as dark mode, just with more tokens. Instead of overriding colors for dark/light, you override tokens for each brand.
:root,
[data-brand="acme"] {
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-accent: #ec4899;
--font-family-heading: 'Inter', system-ui, sans-serif;
--radius-md: 8px;
}
[data-brand="globex"] {
--color-primary: #059669;
--color-primary-hover: #047857;
--color-accent: #f59e0b;
--font-family-heading: 'Poppins', system-ui, sans-serif;
--radius-md: 4px;
}
[data-brand="initech"] {
--color-primary: #dc2626;
--color-primary-hover: #b91c1c;
--color-accent: #2563eb;
--font-family-heading: 'Roboto', system-ui, sans-serif;
--radius-md: 0;
}
Now you can compose brand + theme:
<html data-brand="globex" data-theme="dark">
And every component just works. The button uses var(--color-primary), which resolves to Globex green in light mode, a lighter Globex green in dark mode — all without the button knowing which brand or theme is active.
- 1Themes override alias tokens, not component tokens — keep component tokens referencing aliases
- 2Dark mode is a theme, not a separate component variant — never write .button-dark
- 3Brand tokens include everything that differs between brands: colors, fonts, radii, shadows, spacing
- 4The blocking script pattern prevents FOUC and must run before first paint
- 5Store user preference as system/light/dark — three states, not two
OKLCH for Perceptually Uniform Color Scales
Here is a problem you have definitely encountered: you generate a color scale from light to dark, but some steps look like huge jumps while others are barely distinguishable. That is because hex/RGB colors are not perceptually uniform — mathematically equal steps do not look equal to human eyes.
OKLCH solves this. It is a color space where equal numeric changes produce equal perceived changes:
oklch(L C H)
L = Lightness (0 to 1) — how bright
C = Chroma (0 to ~0.37) — how saturated
H = Hue (0 to 360) — the color angle
Generating a perceptually uniform 10-step scale:
:root {
--color-brand-50: oklch(0.97 0.02 265);
--color-brand-100: oklch(0.93 0.04 265);
--color-brand-200: oklch(0.87 0.08 265);
--color-brand-300: oklch(0.78 0.12 265);
--color-brand-400: oklch(0.70 0.16 265);
--color-brand-500: oklch(0.62 0.18 265);
--color-brand-600: oklch(0.55 0.18 265);
--color-brand-700: oklch(0.48 0.16 265);
--color-brand-800: oklch(0.40 0.12 265);
--color-brand-900: oklch(0.30 0.08 265);
}
Notice the lightness steps are roughly even (0.97, 0.93, 0.87...). In OKLCH, these even steps produce even visual steps. In hex, you would need to manually tweak each value to achieve the same result.
Generating Contrast-Safe Palettes
Accessibility requires minimum contrast ratios: 4.5:1 for normal text, 3:1 for large text (WCAG AA). When generating color scales, you must verify that your text-on-background combinations meet these ratios.
function getContrastRatio(l1, l2) {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// OKLCH lightness values (approximate relative luminance)
// 50 (L=0.97) on 900 (L=0.30) → high contrast, always safe
// 400 (L=0.70) on 900 (L=0.30) → medium contrast, check carefully
// 500 (L=0.62) on 600 (L=0.55) → low contrast, likely fails
The safest approach: define explicit "on-color" tokens that guarantee contrast:
:root {
--color-primary: oklch(0.55 0.18 265);
--color-on-primary: oklch(0.98 0.01 265); /* guaranteed 4.5:1+ contrast */
--color-danger: oklch(0.55 0.20 25);
--color-on-danger: oklch(0.98 0.01 25);
}
OKLCH can express colors outside the sRGB gamut. If you set chroma too high, the color will be clamped by the browser on standard displays, potentially producing unexpected results. Always test your OKLCH values on both wide-gamut (P3) and standard (sRGB) displays. Use color-mix(in oklch, ...) for safe interpolation.
CSS color-mix() for theme-aware computed colors
Sometimes you need colors that are derived from tokens — a 10% transparent version of the primary color for hover backgrounds, or a midpoint between two theme colors. CSS color-mix() lets you compute these at runtime without JavaScript:
.card-hover {
/* 10% of the primary color mixed with the background */
background: color-mix(in oklch, var(--color-primary) 10%, var(--color-bg-primary));
}
.badge {
/* A muted version of the accent */
background: color-mix(in oklch, var(--color-accent) 15%, transparent);
color: var(--color-accent);
}This is theme-aware by default. When the theme changes, the computed colors update because the underlying custom properties changed. No JavaScript needed.
| What developers do | What they should do |
|---|---|
| Using separate CSS files for each theme (theme-dark.css, theme-light.css) Separate files require dynamic stylesheet loading, cause FOUC, and double your CSS maintenance surface | Use CSS custom property overrides scoped to a data attribute |
| Inverting all colors for dark mode (white becomes black, black becomes white) Simple inversion produces harsh, eye-straining results. Dark mode needs independently designed color scales with appropriate contrast ratios | Design dark mode intentionally — dark backgrounds are not pure black, text is not pure white, and contrast must be verified independently |
| Storing theme as a boolean (isDark: true/false) A boolean cannot represent 'follow the operating system' — which is what most users want by default | Store as a three-state value: system, light, dark |
| Defining OKLCH colors with maximum chroma for vibrancy High chroma values exceed sRGB gamut and get clamped unpredictably on standard monitors | Keep chroma moderate (0.12-0.20) and verify on sRGB displays |