Theming an Entire Site at Runtime with CSS Custom Properties

Cover illustration for Theming an Entire Site at Runtime with CSS Custom Properties

Written by

in

Letting visitors switch the accent colour of a whole site sounds like a small feature. In practice it touches every button, border, glow, gradient and chart. If those colours are hard-coded, a “theme switcher” quickly turns into a find-and-replace nightmare. CSS custom properties make it manageable, and with one modern trick, even animatable.

Start from a few base tokens

The mistake I see most often is defining dozens of independent colour variables. Every new palette then needs dozens of new values. Instead, define a tiny set of base stops and derive everything else from them with color-mix():

:root {
  --accent: #ff2a4d;        /* hot */
  --accent-deep: #800013;   /* shadow */
  --accent-light: #ffd2b8;  /* highlight */

  --accent-soft: color-mix(in oklab, var(--accent) 40%, white);
  --surface: color-mix(in oklab, var(--accent-deep) 7%, #050505);
  --fg: color-mix(in oklab, var(--accent-light) 22%, white);
}

A new palette is now three hex values. Mixing in the oklab colour space keeps the derived tints perceptually even, so a blue theme looks as balanced as a red one.

Expose tokens to your utility classes

With Tailwind CSS v4, an inline theme block maps tokens to utilities without hard-coding any colour:

@theme inline {
  --color-accent: var(--accent);
  --color-surface: var(--surface);
  --color-fg: var(--fg);
}

Now bg-accent/15, border-accent/40 and text-fg all follow the live palette, and opacity modifiers still work because Tailwind generates them with color-mix() too. Arbitrary values can reference tokens directly: shadow-[0_0_24px_var(--accent)].

Make the change animate

By default, custom properties are just strings, so the browser cannot interpolate them: a palette change snaps instantly. Registering the base tokens with @property gives them a type, and typed properties can transition:

@property --accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #ff2a4d;
}

:root {
  transition: --accent 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}

Because every derived colour is mixed from the registered ones, the whole interface morphs smoothly when JavaScript sets a new value:

document.documentElement.style.setProperty("--accent", "#a64dff");

Remember the choice without a flash

Saving the palette to localStorage is easy. Applying it without a flash of the default colour is the part people miss. If you wait for React to hydrate, users see red for a moment before their chosen colour appears. A tiny inline script in the document head runs before the first paint:

<script>
  try {
    var p = { violet: ["#3b0a85", "#a64dff", "#f0dcff"] }[localStorage.getItem("palette")];
    if (p) {
      var s = document.documentElement.style;
      s.setProperty("--accent-deep", p[0]);
      s.setProperty("--accent", p[1]);
      s.setProperty("--accent-light", p[2]);
    }
  } catch (e) {}
</script>

Wrap it in try/catch: storage can be blocked in private windows, and a theme script must never break the page. Because the script edits the <html> element, add suppressHydrationWarning to it in React.

Do not forget non-CSS colours

Canvas and WebGL cannot read CSS variables. Keep the palettes in one shared module that both the CSS sync and the render loop import, and ease uniforms toward the new colours each frame. That way the particles, charts and buttons all change together.

Migrating an existing codebase

If your components are full of literal hex codes, a careful script gets you most of the way: map each literal to a token, rewrite bg-[#ff2a4d] to bg-accent, and convert rgba(255,42,77,0.4) to color-mix(in oklab, var(--accent) 40%, transparent). Then review the leftovers by hand. SVG presentation attributes and canvas fill styles are the usual exceptions, since they need a class or a JavaScript value instead.

Takeaways

  • Few base tokens, everything else derived with color-mix().
  • Map tokens into your utility framework instead of hard-coding colours.
  • Register base tokens with @property to make theme changes animate.
  • Apply the saved palette in a head script to avoid a flash.
  • Share palette data with canvas and WebGL code.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *