← Orieb AlzyuotHire me
By Orieb Alzyuot · 12 October 2026 · 12 min read

Theming Done Right: Light, Dark, Brand Palettes and User Preferences

Dark mode is a weekend task. Theming that survives brand palettes, contrast modes, user preferences, Arabic RTL and a design team that keeps adding colours takes architecture. Here is the one I use.

Cover: Theming done right, a circle split between a light and a dark interface with a ring of brand colour swatches

I’ve built theming for products that ship ten brand palettes, light and dark modes, three contrast modes and full Arabic RTL, on both web and mobile from the same tokens. Every hard bug came from one of four places: tokens named by value, themes that restyle components, a flash of the wrong theme on load, and accent colours nobody checked for contrast.

This article walks through each one, with the code I’d ship.

TL;DR

1. Three tiers of tokens

The most expensive theming mistake is a component that says color: $purple-600. When the brand changes, or dark mode arrives, you have to find every one.

Diagram: primitive tokens such as violet-600 feed semantic tokens such as color-accent, which feed component tokens such as button-bg. Themes swap the semantic tier.
Components read semantic or component tokens. Only the middle tier changes per theme.
/* 1 · Primitive: the raw palette, named by what it is */
:root {
  --violet-300: #b9a7ff;
  --violet-600: #6d4aff;
  --neutral-0: #ffffff;
  --neutral-900: #1d1a2b;
  --neutral-950: #151126;
}

/* 2 · Semantic: named by role. Themes remap ONLY these. */
:root,
[data-theme="light"] {
  color-scheme: light;
  --color-bg: var(--neutral-0);
  --color-surface: var(--neutral-0);
  --color-text: var(--neutral-900);
  --color-accent: var(--violet-600);
  --color-on-accent: var(--neutral-0);
  --color-focus: var(--violet-600);
}

[data-theme="dark"] {
  color-scheme: dark;
  --color-bg: var(--neutral-950);
  --color-surface: #211d31;
  --color-text: #eeebf7;
  --color-accent: var(--violet-300);
  --color-on-accent: var(--neutral-950);
  --color-focus: var(--violet-300);
}

/* 3 · Component: optional knobs, defaulting to semantic tokens */
.button {
  --button-bg: var(--color-accent);
  --button-fg: var(--color-on-accent);
  background: var(--button-bg);
  color: var(--button-fg);
}

Some rules that keep this healthy:

2. One attribute per axis, and a written precedence

Real products don’t have one theme switch. They have several independent axes:

Put each one on <html> as its own attribute. Never combine them into a class like .dark-teal-compact. And write down the precedence, because two axes will eventually fight. Mine is:

  1. A contrast mode wins over everything. When it’s on, every brand variable is removed and the contrast stylesheet owns the palette. The user’s brand choice is kept and comes back when contrast mode is switched off.
  2. The brand palette sets surfaces, text, lines and the accent family.
  3. An accent override regenerates only the accent family, on top of the palette’s surfaces.
  4. Mode (light or dark) selects which half of each palette applies.

You might ask whether light-dark() is enough on its own. It’s a great fit when you have exactly two modes and only colours change: color: light-dark(#1d1a2b, #eeebf7) follows color-scheme. Once you have brand palettes, contrast modes or non-colour tokens, attribute-scoped variables stay clearer.

3. System preference plus a user override

Give users three choices, not two: System, Light and Dark. “System” must follow the OS live, including automatic sunset switching, without a reload.

type Mode = 'system' | 'light' | 'dark';
const KEY = 'ui-theme';
const mq = matchMedia('(prefers-color-scheme: dark)');

export function resolvedMode(mode: Mode): 'light' | 'dark' {
  return mode === 'system' ? (mq.matches ? 'dark' : 'light') : mode;
}

export function applyMode(mode: Mode) {
  document.documentElement.dataset.theme = resolvedMode(mode);
  try {
    if (mode === 'system') localStorage.removeItem(KEY);
    else localStorage.setItem(KEY, mode);
  } catch { /* private mode or storage blocked: still works for this visit */ }
}

// Follow the OS while the user is on "system"
mq.addEventListener('change', () => {
  if (!localStorage.getItem(KEY)) applyMode('system');
});

Store system as “no value”. If you store the resolved value, users who chose “System” get stuck on whatever the OS said on that first day.

4. No flash of the wrong theme

If your framework applies the theme after it boots, users on dark mode see a white flash on every hard reload. The fix is old and boring, and it still works: a tiny, synchronous inline script in <head>, before any stylesheet that paints.

<head>
  <meta name="color-scheme" content="light dark">
  <script>
    (function () {
      var d = document.documentElement;
      try {
        var mode = localStorage.getItem('ui-theme');
        if (mode !== 'light' && mode !== 'dark') {
          mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        }
        d.dataset.theme = mode;
        var accent = localStorage.getItem('ui-accent');
        if (accent) d.dataset.accent = accent;
        var contrast = localStorage.getItem('ui-contrast');
        if (contrast) d.dataset.contrast = contrast;
      } catch (e) {
        d.dataset.theme = 'light';
      }
    })();
  </script>
  <link rel="stylesheet" href="/styles.css">
</head>

Some details that matter:

5. Deriving a palette from one seed colour

Brand teams give you one colour. You need a page background, card surfaces, lines, text, muted text, the accent, text on the accent, hover states and tints, all for both light and dark, and all accessible.

My generator takes a seed and moves it until the contrast targets pass. It changes the lightness in small steps, keeping hue and saturation, so the brand still reads as the brand:

/** Walk lightness 1% at a time (hue and saturation kept) until every check passes. */
function shiftToContrast(hex: string, ok: (c: string) => boolean, dir: -1 | 1): string {
  const [h, s, l] = toHsl(hex);
  for (let i = 0; i <= 100; i++) {
    const c = fromHsl([h, s, clamp01(l + dir * i / 100)]);
    if (ok(c)) return c;
  }
  return dir < 0 ? '#000000' : '#ffffff';
}

export function derivePalette(seed: string, mode: 'light' | 'dark') {
  const dark = mode === 'dark';
  const page = dark ? mix('#0e0d16', seed, 0.07) : mix('#ffffff', seed, 0.05);
  const card = dark ? mix(page, '#ffffff', 0.05) : '#ffffff';
  const accent = dark
    ? shiftToContrast(seed, c => contrast(c, card) >= 4.5 && contrast(c, page) >= 4.5, +1)
    : shiftToContrast(seed, c => contrast(c, card) >= 4.5 && contrast('#ffffff', c) >= 4.5, -1);
  return { page, card, accent, onAccent: dark ? mix(page, '#000000', 0.45) : '#ffffff' };
}

contrast() is the standard WCAG 2.x relative-luminance ratio. Here is what it does to four real seeds:

Four seed colours (violet, teal, gold, coral) with their raw contrast on white and the adjusted light and dark accents with computed contrast ratios
Gold fails on white at 2.27:1. Darkened in place to #936a00, it reaches 4.88:1 and still reads as gold.

The targets I enforce at generation time:

A note on APCA: it’s a promising perceptual contrast model, but WCAG 3 is still a draft and isn’t normative. Use WCAG 2.2 ratios for compliance, and consider APCA as an extra design signal if your team wants one.

6. Contrast-safe accents in the UI

A colour picker for “your brand colour” is a contrast bug generator, unless the UI shows the result and fixes it:

7. Persisting the choice

Store the user’s decision, not just the output:

Also: encrypting these localStorage values doesn’t make them secret, because the key ships in your bundle. It only stops casual tampering. It’s fine for preferences, but keep it in mind for anything sensitive. The security article in this series has more on that.

8. Animating the switch: a View Transitions circle reveal

A theme switch is a whole-page change, which is exactly what the View Transitions API is for. The browser snapshots the old page, you apply the new theme, and then you animate between the two snapshots.

Animation: clicking the dark-mode toggle reveals the dark theme in a growing circle from the toggle, then the accent changes from violet to teal, then the light theme is revealed again
Circle reveal from the toggle, a contrast-checked accent change, and back.
export function switchTheme(apply: () => void, origin?: { x: number; y: number }) {
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  if (!document.startViewTransition || reduce) {
    apply();
    return;
  }

  // From the pointer if there was one, else from the top centre (keyboard users)
  const x = origin?.x ?? innerWidth / 2;
  const y = origin?.y ?? 0;
  const r = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));

  const t = document.startViewTransition(apply);
  t.ready.then(() => {
    document.documentElement.animate(
      { clipPath: [`circle(0 at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },
      { duration: 600, easing: 'cubic-bezier(.65, 0, .2, 1)', pseudoElement: '::view-transition-new(root)' },
    );
  });
}
/* Let our clip-path animation run instead of the default cross-fade */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}

What I’ve learned shipping this:

9. Theming in RTL

Colour tokens don’t care about direction, but a few theme-level things do:

10. Live demo: theme playground

Pick a mode, pick a brand seed, or choose your own, and watch the contrast-safe adjustment work. The mode switch uses the circle reveal above, and your choice is saved in this page’s localStorage.

Screenshot of the theme playground: mode switch, brand seed swatches, contrast ratio badge and a themed preview card
The playground: mode, seed colour, contrast-safe toggle and live semantic tokens.

▶ Try it live: https://oriebalzyuot96.github.io/blog/theming-done-right/#demo-theme

11. Testing themes

Themes multiply your test matrix, so automate the boring parts:

What I’d do on a new product

Done right, a new brand palette becomes a config entry and a passing test, not a sprint.


About the author

Orieb Alzyuot (عريب الزيوت) is a Senior Front-End & UI/UX Engineer based in Amman, Jordan, with 6+ years of experience building government, fintech, HR and enterprise products used every day. Her work includes:

Open to senior and lead front-end roles, contracts and freelance work, remote or with relocation.

Find me here:

If this helped, follow me on Medium for more on design systems, accessibility, Angular, React and front-end architecture. 💜