← Back to Writing
4 min read

CSS Custom Properties for Runtime Theming


Tailwind 4 introduced a CSS-first configuration model. Instead of a JavaScript config file, you write @theme blocks in CSS. That's a step in the right direction, but it doesn't solve runtime theming on its own.

The problem with static tokens

Tailwind generates utility classes at build time. A class like bg-zinc-900 is resolved to a fixed hex value. There is no mechanism to change what that class means after the CSS is shipped.

CSS custom properties change at runtime

CSS custom properties (also called CSS variables) are re-evaluated by the browser on every paint. You can redefine them on any element and all descendants pick up the change instantly — no JavaScript re-render required.

:root {
  --color-bg: #ffffff;
  --color-ink: #111111;
}

[data-theme="dark"] {
  --color-bg: #0a0a0a;
  --color-ink: #ededed;
}

Setting document.documentElement.dataset.theme = 'dark' is all it takes to flip the entire page.

Bridging with Tailwind 4

In Tailwind 4, the @theme block can reference CSS variables:

@theme {
  --color-ink: var(--color-ink);
}

This registers the token so text-ink is a valid utility class, and its resolved value comes from the custom property at runtime. You get Tailwind's ergonomics and runtime flexibility without a theme library.

The anti-flash pattern

One catch: on initial load, React has not run yet. If you set the theme in a useEffect, users see a flash of the wrong theme. The fix is a tiny inline <script> in <head> that reads localStorage and sets the data-theme attribute synchronously, before the first paint.