← All notes

One attribute, five skins — re-skinning a UI through the cascade

May 29, 20265 min read

The admin UI for El Camioncito — a taquería order system I run on a small VPS — ships five color themes: dark, light, sepia, pink, and blue. The interesting part isn't that it has themes. It's how little it costs to switch between them.

There is no theme provider re-rendering a tree of components. There is no round-trip to a server. There isn't even a CSS-in-JS recompile. You click a swatch, one attribute changes on one element, and the entire interface re-skins before the next frame. Below is that mechanism, stripped down to a storefront and an order card. Click the swatches.

The whole trick

Every color in that UI is a CSS custom property, not a literal. The order card doesn't say background: #1A1A2E — it says background: var(--ts-card). The accent button reads var(--ts-accent). Twelve variables in total: six structural (bg, surface, card, border, text, text-muted) and the rest accents and derived utilities.

Those variables are defined in a block scoped to the root element. Each theme is one such block, keyed by a data-theme attribute:

.demo-root[data-theme="dark"]  { --bg: oklch(20% 0.018 240); --accent: oklch(74% 0.150 240); /* ... */ }
.demo-root[data-theme="light"] { --bg: oklch(97% 0.005 85);  --accent: oklch(56% 0.160 240); /* ... */ }
.demo-root[data-theme="sepia"] { --bg: oklch(93% 0.028 75);  --accent: oklch(52% 0.130 50);  /* ... */ }

Switching themes is therefore one line:

root.setAttribute('data-theme', 'sepia');

The cascade does the rest. Custom properties inherit, so the moment the attribute flips, every descendant that reads var(--bg) resolves to the new value. The browser repaints. React never re-renders anything — the component tree is identical before and after; only the computed styles changed. This is the difference between theming with the platform and theming against it.

Why an attribute and not a class

You can do the same with a class (className="theme-sepia") and many systems do. I prefer the attribute for two reasons:

  • It's single-valued by nature. A class list can accumulate theme-dark theme-sepia by accident; data-theme holds exactly one value. Toggling is a set, never an add/remove dance.
  • It reads as state, not styling. data-theme="sepia" is self-documenting in the DOM inspector and in CSS selectors. Anyone reading [data-theme="sepia"] { ... } knows it's a mode, not a decoration.

In the demo I assign the resolved variables directly on the root's inline style as well, so the swap survives even outside a stylesheet — but the attribute is the source of truth and what you'd inspect.

The OKLCH part — why these themes look balanced

This is where it ties back to the rest of this site. The portfolio you're reading runs two themes, rolando-light and rolando-dark, built on OKLCH triadic harmony: three accent hues spaced 120° apart on the hue circle, chosen for the same chroma so none of them shouts louder than the others.

Each demo theme carries its own triad. Read the swatch buttons — they're not decorative, they're literally [bg, accent, accent2, accent3] for that theme. In OKLCH that's trivial to express: hold lightness and chroma steady, step the hue by 120.

accent  = `oklch(74% 0.150 ${h})`
accent2 = `oklch(74% 0.150 ${h + 120})`
accent3 = `oklch(74% 0.150 ${h + 240})`

Try that in HSL and the three "equal" hues come out perceptually lopsided — HSL yellow is far brighter than HSL blue at the same lightness value, because HSL lightness is a lie. OKLCH lightness is perceptual, so equal L means equally bright to your eye. That's the entire reason this color system uses it.

Accent-lift

One detail I lifted straight from this portfolio's own CSS: on the dark themes (dark, pink, blue) the accents sit at a higher lightness than on the light themes (light, sepia). An accent that reads as a confident mid-tone on a paper background looks muddy and recessed on a near-black one. Bumping it ~14% in L for the dark skins restores the perceptual punch — the accent feels equally present regardless of what it sits on. Same hue, same chroma, lightness compensated per background. OKLCH makes that a one-number adjustment instead of a guessing game.

What it costs

Practically nothing, which is the point.

  • No re-render. The React tree is stable across a theme switch; the work is a single attribute write and a browser repaint. On a slow phone the swatch click and the repaint are indistinguishable from instant.
  • No flash, no backend. The theme map is static and lives in the bundle. There's no fetch, no loading state, nothing to await.
  • One place to add a sixth theme. Define a new token block, add its id to the list, done. No component knows or cares how many themes exist — they all just read variables.

The demo seeds its default theme deterministically (dark, matching what the server rendered) and only reads the persisted choice from localStorage after mount, inside an effect — so there's no hydration mismatch and no first-paint flicker. The real admin does the same, plus it broadcasts the change across every mounted switcher so the mobile row and the desktop sidebar stay in sync.

That's the whole feature. A map of OKLCH variables, one attribute to pick which map is live, and the cascade doing the work it was designed to do.