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