Six themes in OKLCH: a CSS variable theme studio
How zaidxshaikh's six themes work — Tailwind 4 @theme tokens, a single data-attribute swap, smooth body transitions, and a 3D canvas that re-tints to match.
Phase 1 of zaidxshaikh had one theme: void. Phase 2 has six — Void, Sunset, CRT, Cyber, Mono, and Zen — selectable from the ⌘K palette. They persist in localStorage, body color crossfades when you flip themes, and the 3D canvas re-tints to match.
No dependencies were added. The whole system is ~100 lines of CSS plus a 25-line client component.
Why OKLCH
Most theme systems use HSL or sRGB hex values. Both have a problem: perceptual non-uniformity. A 10% lightness shift in HSL doesn't feel like a 10% shift across hues — yellows turn muddy, blues stay vibrant.
OKLCH solves this. Lightness is perceptually uniform. Chroma is just chroma. Hue is hue. You can shift a hue 30° and the lightness reads the same way to the eye.
That mattered for the theme studio because every theme overrides the same nine tokens (--color-void, --color-fog, --color-aurora, --color-plasma, --color-ion, etc.), and they all need to feel consistent within their palette. With OKLCH I could write each theme by ear — bump lightness, shift hue — and trust that it'd come out balanced.
The base layer
Tailwind 4's @theme block in globals.css defines the default (Void) palette:
@theme {
--color-void: oklch( 8% 0.02 270);
--color-fog: oklch(95% 0.01 270);
--color-aurora: oklch(78% 0.18 160);
--color-plasma: oklch(70% 0.24 320);
--color-ion: oklch(85% 0.15 220);
--color-surface: oklch(12% 0.02 270);
/* ... */
}These flow through to Tailwind utilities like bg-void and text-fog automatically.
The override layer
Each non-default theme is a single :root[data-theme="..."] block that re-declares the same tokens:
:root[data-theme="crt"] {
--color-void: oklch( 7% 0.02 145);
--color-fog: oklch(92% 0.18 145);
--color-aurora: oklch(84% 0.22 145);
/* phosphor green across the board */
}
:root[data-theme="zen"] {
color-scheme: light; /* flip native color-scheme */
--color-void: oklch(97% 0.01 85); /* paper */
--color-fog: oklch(18% 0.01 85); /* ink */
/* ... */
}Crucially, only the variable values change. Every component reads from var(--color-aurora) already, so theme flipping is just a one-attribute mutation on <html>.
The applier
A 25-line client component watches the Zustand store and sets the attribute:
export function ThemeApplier() {
const theme = useUi((s) => s.theme);
useEffect(() => {
const root = document.documentElement;
if (theme && theme !== DEFAULT_THEME) {
root.setAttribute("data-theme", theme);
} else {
root.removeAttribute("data-theme");
}
}, [theme]);
return null;
}That's it. Mounted in Providers. The store is persisted via Zustand's persist middleware, so the theme survives reloads.
The transition
Without a CSS transition the swap is jarring — colors snap, lose continuity. One line fixes it:
body {
transition: background-color 0.4s var(--ease-out-expo),
color 0.4s var(--ease-out-expo);
}400ms feels like an intentional crossfade. Less than 200ms feels accidental; more than 600ms feels slow.
The 3D canvas problem
Most of the site responds to the CSS variable swap automatically. The 3D hero doesn't — Three.js renders into a WebGL canvas with its own color buffer that knows nothing about CSS.
When you flip to Zen (light mode), the page goes paper-white but the 3D scene stays pitch-black. Looks broken.
Fix: pass the theme's preview hex into the Scene as a prop:
const themeMeta = THEMES.find((t) => t.id === themeId) ?? defaultTheme;
const bgColor = themeMeta.preview.void;
<Hero3D bgColor={bgColor} ... />Inside Hero3D:
<color attach="background" args={[bgColor]} />
<fog attach="fog" args={[bgColor, 8, 22]} />Now picking Zen flips the canvas to off-white too. The orbs stay readable because their tint colors come from the same theme metadata.
Palette UX
In the command palette, every theme item renders a 3-stripe color swatch — void / aurora / plasma — so users see the palette before committing:
<span className="flex h-4 w-7 overflow-hidden rounded-full">
<span className="w-1/3" style={{ background: themeMeta.preview.void }} />
<span className="w-1/3" style={{ background: themeMeta.preview.aurora }} />
<span className="w-1/3" style={{ background: themeMeta.preview.plasma }} />
</span>Tiny detail, massive UX upgrade — picking a theme blind is a worse experience than picking one with a visual preview.
The cost
- No new npm dependencies
- +1 KB First Load (the persisted theme state)
- ~100 lines of CSS overrides
- ~25 lines of TS for the applier
- 6 themes, each takes about 5 minutes to author when you're typing OKLCH by ear
The CRT theme combined with the Konami easter egg is unreasonably satisfying. Try it.