HTML & CSS

CSS custom properties and a dark mode that respects the system setting

How CSS custom properties (variables) cascade and inherit, how to build a colour token set, and a light/dark theme that follows prefers-color-scheme, offers a manual toggle, and never flashes the wrong theme on load.

By the TechTutorZone editorial team · Published · 6 min read (1,155 words)

Custom properties (the --name values often called CSS variables) are the mechanism behind almost every theme switcher. Set the colours once as properties, use them everywhere, and change the properties to change the theme. This tutorial explains how they actually behave, which is slightly different from variables in a programming language, then builds a complete dark mode: automatic from the operating system, overridable with a button, remembered between visits, and without the flash of white that most implementations show on reload. This site uses exactly this pattern.

How custom properties work

A custom property is declared like any other property, on any element, and read with var():

:root { --accent: #0b5cad; }
a       { color: var(--accent); }
button  { background: var(--accent, blue); }   /* fallback if unset */

Three rules explain nearly everything about them:

  1. They inherit. A property set on :root (the <html> element) is available on every element below it. Set it on .card and only the card's descendants see that value.
  2. They cascade like normal properties. A later or more specific declaration wins. :root[data-theme="dark"] { --accent: ... } overrides :root { --accent: ... } because the attribute selector is more specific.
  3. var() is substituted at use time, per element. If --accent changes on an ancestor, every descendant that uses it updates, with no recomputation needed in your code. This is the whole reason theming is cheap.

One consequence surprises people: a custom property holds a raw token, not a typed value. --size: 16 is not a length, and width: var(--size)px does not work (it becomes 16 px, two tokens). Store --size: 16px, or use calc(var(--size) * 1px).

Design the tokens

Name tokens by purpose, not by appearance. --text, --bg, --line, --link survive a theme change; --dark-grey becomes a lie the moment dark mode makes it light grey. A small set covers most sites:

:root {
  --bg: #ffffff;
  --bg-2: #f6f8fa;       /* cards, code blocks, slightly off the page */
  --text: #1f2328;
  --muted: #59636e;      /* secondary text; still 4.5:1 on --bg */
  --line: #d0d7de;
  --link: #0b5cad;
  --focus: #e36209;
}
body { background: var(--bg); color: var(--text); }
a    { color: var(--link); }
.card, pre { background: var(--bg-2); border: 1px solid var(--line); }

Check every text colour against every background it sits on with a contrast checker: the muted grey on the off-white card background is the pair that most often fails.

Follow the system setting

Operating systems expose the user's preference, and browsers pass it to CSS as the prefers-color-scheme media feature. Redefine the tokens inside it and nothing else needs to change:

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0f1115;
    --bg-2: #161a21;
    --text: #e6e8eb;
    --muted: #a3acb8;
    --line: #2b313b;
    --link: #7cb8ff;
    --focus: #ffb454;
  }
}

Add <meta name="color-scheme" content="light dark"> in the head (or color-scheme: light dark on :root). This tells the browser to render its own UI, form controls, scrollbars, the default background, in the matching scheme, which is why some sites show a white scrollbar track on a dark page: they forgot it.

Dark palettes are not inverted light palettes. Pure black backgrounds with pure white text produce halation (the text appears to glow) for many readers; a very dark grey with an off-white is easier. Saturated colours that work on white become garish on dark; links usually need to be lighter and less saturated. Shadows disappear on dark backgrounds, so elevation is better shown with a lighter surface colour.

Add a manual toggle

Some people want dark mode on one site but not their whole system. Give the override its own selector, more specific than the media query's, and make the media query step aside when the override is set:

/* system preference, unless the user chose light explicitly */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bg: #0f1115; /* ...dark tokens... */ }
}
/* explicit dark choice, regardless of the system */
:root[data-theme="dark"] { --bg: #0f1115; /* ...the same dark tokens... */ }

The dark tokens appear twice. That is the cost of supporting both automatic and manual; keep them next to each other so they cannot drift. The JavaScript for the button is short:

const root = document.documentElement;
const button = document.getElementById('theme-toggle');

button.addEventListener('click', () => {
  const explicit = root.getAttribute('data-theme');
  const systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
  const isDark = explicit ? explicit === 'dark' : systemDark;
  const next = isDark ? 'light' : 'dark';
  root.setAttribute('data-theme', next);
  try { localStorage.setItem('theme', next); } catch (e) { /* private mode: ignore */ }
});

The current theme has to be worked out from both sources: an explicit choice if there is one, otherwise the system. Storage can throw in private browsing or when cookies are blocked, so the write is wrapped.

Avoid the flash on reload

If the stored choice is applied by a script at the bottom of the page or in a deferred file, the page first paints with the default theme and then switches. On a dark theme that is a white flash on every navigation. The fix is a tiny inline script in the <head>, before the stylesheet, that sets the attribute synchronously:

<head>
  <meta charset="utf-8">
  <script>
    try {
      var t = localStorage.getItem('theme');
      if (t) document.documentElement.setAttribute('data-theme', t);
    } catch (e) {}
  </script>
  <link rel="stylesheet" href="/css/site.css">
</head>

It is two lines, runs before anything renders, and is the one legitimate use of a render-blocking inline script. Everything else about the theme can load later.

Pitfalls

  • Hard-coded colours left behind. One color: #333 in a component becomes invisible on dark. Search the stylesheet for # and rgb( outside the token block.
  • Images and logos. A black logo on a transparent PNG vanishes in dark mode. Use an SVG with fill: currentColor, or supply two images with <picture> and a media="(prefers-color-scheme: dark)" source.
  • Transitions on the switch. transition: background .3s on everything makes the whole page crossfade when the toggle is pressed, including things that should not animate. If you want it, scope it to body and respect prefers-reduced-motion.
  • Forgetting color-scheme. Native form controls, the scrollbar and the page's default canvas stay light without it.
  • Custom properties in media queries. @media (min-width: var(--bp)) does not work; media queries are evaluated outside the cascade. Use a preprocessor or repeat the number.

Exercise

Apply the pattern to a page of your own: move every colour into tokens, add the dark block, the toggle and the inline script. Then open developer tools, switch "Emulate CSS prefers-color-scheme" (Rendering panel in Chrome) between light and dark with no stored choice, and confirm the page follows. Set a choice with the button, reload, and confirm there is no flash and the choice wins over the emulated setting.

Summary

  • Custom properties inherit and cascade; var() is resolved per element at use time, which is why redefining a token re-themes everything under it.
  • Name tokens by role; redefine them under prefers-color-scheme: dark and under [data-theme="dark"].
  • Set color-scheme so native UI matches.
  • Apply the stored choice from an inline script in the head to avoid the flash.