Get in touch
Design

Building a palette switcher

The hard part of a theme switcher is not the switching. It is the eighth of a second before React exists.

By Ovadev2 min read

All posts
  • css
  • theming
  • hydration

Try the switcher at the top of this page. Ten palettes, light and dark, and every mark, chip and article header on the site repaints — the same slots the pixel system is drawn in. There is no JavaScript involved in the repaint at all — the work is in never letting a component name a colour.

Everything is a slot#

One rule, enforced by review: a component may reference var(--accent) or var(--c1)var(--c6), and it may derive from them with color-mix(). It may not contain a hex code. A palette is then just eight declarations.

css
:root{
  --accent-raw:#1F35E6;
  --accent:var(--accent-raw);
  --accent-deep:color-mix(in srgb,var(--accent) 74%,#000);
  --accent-soft:color-mix(in srgb,var(--accent) 46%,var(--bg));
}
[data-palette="ember"]{
  --accent-raw:#D8502E;
  --c1:#D8502E; --c2:#E8912A; --c3:#B5372E;
  --c4:#EFB13C; --c5:#8C4A3E; --c6:#3E2A28;
}

color-mix() is what removes the ninety-line palette file. Every tint, shade and hairline is derived from the one hue the palette declares, which also means a new palette cannot forget to define one of them.

The flash, and how it is actually fixed#

The preference lives in localStorage. React reads it after hydration, which is roughly 150ms too late — long enough for a light page to paint and then snap to dark. Every trick that tries to hide this with a transition is treating the symptom.

The fix is a small blocking script in <head> that reads the preference and stamps the attributes on <html> before the first paint.

html
<script>
  try {
    const s = localStorage;
    const t = s.getItem("theme");
    const p = s.getItem("palette");
    if (t) document.documentElement.dataset.theme = t;
    if (p) document.documentElement.dataset.palette = p;
  } catch {}
</script>

Dark is not the same palette, dimmed#

Each multi-colour palette gets a second definition for dark, with the mid-tones lifted. A hue that reads confidently on #EFEDE4 disappears into #0B0C0F; automatic dimming produces mud in one direction and neon in the other.

The test that keeps it honest#

Any new component gets looked at in three combinations before it ships: the default palette in light, a monochrome palette, and a multi-colour palette in dark. Monochrome is the sharpest of the three — if a component stops being readable when every slot is a tone of one hue, it was using colour to carry meaning that value should have carried.

Ovadev

A software company in Baar, Zug. We build our own products, run them afterwards, and write down what that teaches us.

Get in touch
Read next

More from the studio — design first, then the rest of the feed.

All posts
© 2026 Ovadev GmbHMade in Switzerland