all posts
May 16, 2026·4 min read·csstailwindthemingoklch

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.