Dynamic Theming with CSS Custom Properties and JavaScript Theme Switching

Build a theme toggle that reads prefers-color-scheme on first visit, persists user overrides in localStorage, and sets a data-theme attribute before first paint to prevent flicker.

CSS custom properties dynamic theming JavaScript is the technique that lets a site switch its entire visual identity by changing one attribute on the root element, and it works because custom properties cascade and inherit like any other CSS property. The cascade is not a bug you fight; it is the machinery you ride. A custom property declared on flows down to every descendant, and the var() function reads it wherever it is needed. That is the whole architecture: you define design tokens as custom properties, you assign those tokens to every themed rule, and you flip the tokens at runtime by setting a single attribute. The flip is instant because the computed value of every themed property changes in the same style recalculation pass. No class toggling on individual components, no duplicated rule blocks, no JavaScript that touches more than one element. The technique this replaces is the old pattern of putting a theme class on the body element and writing every themed rule twice, once under .light and once under .dark, which doubles the size of your stylesheet and forces a full cascade re-evaluation every time the class changes. Custom properties collapse that into one rule per property and one attribute change on the root.

Build The Minimal Toggle

The minimal light/dark toggle is three files, but the logic lives in exactly one place: the CSS custom property declaration block. You define two theme palettes under attribute selectors on the root, and you write every component style once, referencing the tokens with `var()`. The attribute selector pattern uses a `data-theme` attribute on the `` element, so the selector looks like `html[data-theme='dark']`. That attribute is the only thing JavaScript ever changes. The first sample is complete and runnable on its own: an HTML file with a button, a style block, and a script. The style block declares the tokens for light mode in the `:root` rule, then overrides them for dark mode in the `html[data-theme='dark']` rule. The button script reads the current attribute, flips it to the other value, and stores the choice in `localStorage`. That is the entire loop. Nothing else needs to know the theme exists.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Minimal Theme Toggle</title>
<style>
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --accent: #0066cc;
  color-scheme: light;
}
html[data-theme="dark"] {
  --bg: #1a1a1a;
  --text: #f0f0f0;
  --accent: #66aaff;
  color-scheme: dark;
}
body {
  background: var(--bg);
  color: var(--text);
  font-family: system-ui, sans-serif;
  margin: 2rem;
  transition: background 0.2s, color 0.2s;
}
button {
  background: var(--accent);
  color: var(--bg);
  border: none;
  padding: 0.5rem 1rem;
  border-radius: 4px;
  cursor: pointer;
}
</style>
</head>
<body>
<button id="toggle">Toggle theme</button>
<script>
const root = document.documentElement;
const toggle = document.getElementById('toggle');
toggle.addEventListener('click', () => {
  const current = root.getAttribute('data-theme') || 'light';
  const next = current === 'light' ? 'dark' : 'light';
  root.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
});
</script>
</body>
</html>

Browser Chrome Must Match Your Theme

The meta `name='color-scheme'` tag and the CSS `color-scheme` property are separate mechanisms that control browser chrome theming, not the page theme itself, and both must be set alongside the custom property theme. The meta tag tells the browser that the page supports both light and dark rendering for the default controls like scrollbars and form widgets. The `color-scheme` property on `:root` tells the rendering engine which palette to use for those same native controls. Neither one changes your background or text colours. If you set only the custom properties, the page flips but the scrollbar stays light in dark mode, which looks broken. If you set only `color-scheme`, the scrollbar flips but the page stays light, which is worse. The failure mode is thinking one is the other. They are two layers of the same switch, and the custom property layer is the one your design tokens live in. The other layer exists so the browser does not draw a white scrollbar on a dark page, and it is not optional if you want the whole screen to match.

Kill The Flash Of Wrong Theme

Where the minimal toggle falls short is first visit. A user who has never pressed the button has no `data-theme` attribute, so the page renders the default `:root` light palette, even if their system is dark. That is the flash of incorrect theme: the light palette paints for one frame, then the script runs and flips to dark. The fix is an inline script in the `` that reads the saved preference or the system preference and sets the `data-theme` attribute before the first paint. The script must run before the stylesheet is applied, so it goes in the head, not at the end of the body. It reads `localStorage` for a saved override, falls back to `window.matchMedia('(prefers-color-scheme: dark)')`, and sets the attribute on `document.documentElement`. That is the whole flash prevention strategy: the attribute is present before any CSS is computed, so the dark palette is the initial render, not a post-paint correction.

The Script That Goes In The Head

The critical inline script that sets the `data-theme` attribute before the first paint to prevent FOUC is short enough to paste into any page, and it must be synchronous. If you defer it or make it async, the browser may paint before it runs, which reintroduces the flash. The script uses the `localStorage` persistence key the same way the toggle button does, so the second visit matches the first. Here is the complete sample, ready to drop into the head of any HTML document:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script>
(function() {
  var stored = localStorage.getItem('theme');
  var systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  var theme = stored || (systemDark ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<style>
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --accent: #0066cc;
  color-scheme: light;
}
html[data-theme="dark"] {
  --bg: #1a1a1a;
  --text: #f0f0f0;
  --accent: #66aaff;
  color-scheme: dark;
}
body {
  background: var(--bg);
  color: var(--text);
  font-family: system-ui, sans-serif;
}
</style>
</head>
<body>
<p>This page renders dark on first paint if your system is dark or if you saved a dark choice. No flash.</p>
<script>
var toggle = document.createElement('button');
toggle.textContent = 'Toggle theme';
document.body.appendChild(toggle);
toggle.addEventListener('click', function() {
  var root = document.documentElement;
  var current = root.getAttribute('data-theme') || 'light';
  var next = current === 'light' ? 'dark' : 'light';
  root.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
});
</script>
</body>
</html>

That inline script is the part most tutorials skip, because it is not glamorous and it is not CSS. But it is the difference between a theme that works and a theme that flashes. The `localStorage` persistence is a single line, and the system preference detection is a single `matchMedia` call. The attribute set is the only side effect, and it happens before the first style recalculation. If you are building a multi-page site, this script goes on every page, or better, in the shared head that every page includes. The toggle button on one page writes to `localStorage`, and the next page reads it before paint. That is how the whole site stays consistent without a page reload flash.

Custom Properties Versus Class-Based Theming

The class-based approach puts a class like `.dark` on the body element and writes every themed rule under both selectors. That means every property that depends on the theme appears twice in the stylesheet, and the browser must evaluate both rule blocks during the cascade. With custom properties, the themed rule appears once, referencing `var(--token)`, and the token changes in one place. The cascade still runs, but it runs over the token definition, not over every component rule. The practical difference is measurable in large stylesheets: a CSS file with theming duplicated across two classes doubles the number of rules the cascade has to resolve. Custom properties collapse that to a single rule per token, and the cascade resolves the token once. The specificity of the attribute selector is the same as the class selector, `(0,1,0)`, so moving from `body.dark` to `html[data-theme='dark']` does not change the specificity game. What changes is the number of rule blocks that have to be written and maintained.

The Silent Failures To Watch For

The failure mode most people hit first is trying to set a custom property with `element.style['--name']` instead of `setProperty`. The bracket notation and the dot notation both fail silently in most browsers because the `CSSStyleDeclaration` object does not expose custom properties as named properties. The correct call is `element.style.setProperty('--name', 'value')`, and the same goes for reading: `getComputedStyle(element).getPropertyValue('--name')` returns the computed value as a string. The second most common failure is expecting `var()` to work inside a `url()` function, like `url(var(--bg))`. That does not parse, because `var()` resolves to a value token, and `url()` expects a URL token. The workaround is to put the full `url()` in the custom property, or to use a data URI. Both failures produce no error in the console, just a silently broken style, which makes them hard to debug. The rule is: custom properties hold declaration values, not fragments of them.

Respect The System Preference

The `prefers-color-scheme` media query is the system signal, and the JavaScript override is the user's explicit choice. The two are not in conflict; they are a hierarchy. The system preference is the default, and the override sits on top of it. The `matchMedia` API exposes the system preference to JavaScript, and the change event fires when the user flips their OS setting while the page is open. You can listen to that event and update the `data-theme` attribute if no stored override exists. That is the polite behaviour: a user who has not chosen a theme follows their system, and a user who has chosen gets their choice respected. The override is stored in `localStorage`, and the system preference is rechecked on every page load. The order of checks in the inline script is stored override first, then system preference, then the light default. That order is what makes the first visit correct and the second visit correct.

The Attribute Selector Is Your Public API

The `data-theme` attribute selector pattern is a naming convention that turns an attribute value into a theme key. The selector `html[data-theme='dark']` matches when the value is exactly 'dark', and it has the same specificity as a class selector. The pattern scales to any number of themes because each theme is just another attribute value with its own rule block. The attribute lives on the root element, which is the top of the inheritance chain, so every custom property declared under it cascades to every element. The pattern does not require any JavaScript framework, no data binding, no reactive state. JavaScript sets the attribute, and the cascade does the rest. The attribute itself is a string, so it is easy to serialise into `localStorage` and read back. The pattern is the public API of the theme system: the design system defines the attribute values, and the JavaScript only ever writes one of those values.

When To Use The light-dark() Function

The `light-dark()` CSS function is a newer addition that lets you write a single rule that resolves to one value in light mode and another in dark mode, without writing two rule blocks. The function takes two arguments, light value first, dark value second, and it resolves based on the `color-scheme` property. That means it pairs with `color-scheme`, not with the `data-theme` attribute directly. If you use `light-dark()` inside a custom property, the resolution happens at computed value time, and the `data-theme` attribute must also set `color-scheme` for the function to know which branch to take. The practical use is for one-off values where you do not want to define a token, but it has a cognitive cost: the theme logic is split between custom properties and the function. For a design token system, custom properties with `var()` are the more maintainable choice, because every theme token lives in one declaration block. `light-dark()` is a shortcut, not a replacement for the token architecture.

Scale To Three Themes With Abstract Tokens

The multi-theme system takes the same `data-theme` pattern and extends it to three or more palettes. The architecture is identical: one attribute on the root, one rule block per theme value, and every component style references the same token names. The difference is that the token names are abstract, not tied to a colour's real name. Instead of `--dark-bg`, you define `--surface`, `--text-primary`, `--text-secondary`, `--accent`, `--border`, `--shadow`. Each theme block assigns different values to those tokens. The component stylesheet never mentions a theme name; it only uses `var(--surface)` and `var(--text-primary)`. Adding a fourth theme is one new rule block, not a rewrite of the components. This is the design token approach, and it is the reason custom properties are the runtime theming API: the tokens are the contract between the theme definitions and the components.

Below is a complete multi-theme sample with three palettes: light, dark, and sepia. The sepia theme is a real use case for readability on e-ink screens and for users who want a warmer tint. The JavaScript is the same toggle logic, but the button cycles through three values instead of flipping between two. The custom property set is identical across themes, so the components do not care how many themes exist.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script>
(function() {
  var stored = localStorage.getItem('theme');
  var themes = ['light', 'dark', 'sepia'];
  var theme = stored && themes.indexOf(stored) !== -1 ? stored : 'light';
  document.documentElement.setAttribute('data-theme', theme);
})();
</script>
<style>
:root {
  --surface: #ffffff;
  --text: #1a1a1a;
  --accent: #0066cc;
  --border: #d0d0d0;
  color-scheme: light;
}
html[data-theme="dark"] {
  --surface: #1a1a1a;
  --text: #f0f0f0;
  --accent: #66aaff;
  --border: #3a3a3a;
  color-scheme: dark;
}
html[data-theme="sepia"] {
  --surface: #f4ecd8;
  --text: #4a3f35;
  --accent: #8b5a2b;
  --border: #d4c4a0;
  color-scheme: light;
}
body {
  background: var(--surface);
  color: var(--text);
  font-family: Georgia, serif;
  margin: 2rem;
  border: 1px solid var(--border);
  padding: 2rem;
}
button {
  background: var(--accent);
  color: var(--surface);
  border: none;
  padding: 0.5rem 1rem;
  cursor: pointer;
}
</style>
</head>
<body>
<h1>Multi-theme demo</h1>
<p>Three palettes, one token set.</p>
<button id="cycle">Cycle theme</button>
<script>
var root = document.documentElement;
var button = document.getElementById('cycle');
var themes = ['light', 'dark', 'sepia'];
var current = root.getAttribute('data-theme') || 'light';
button.addEventListener('click', function() {
  var idx = themes.indexOf(current);
  var next = themes[(idx + 1) % themes.length];
  root.setAttribute('data-theme', next);
  current = next;
  localStorage.setItem('theme', next);
});
</script>
</body>
</html>

Get Persistence Right

The `localStorage` persistence mechanism is for the user's explicit choice, not for the system preference. If the user has never chosen, you should not store anything, because the system preference can change. If you store the system preference as if it were a choice, then the user flips their OS to dark, and your site stays light or dark depending on what you stored. That is a stale override. The correct behaviour is to store only when the user presses the toggle, and to treat missing storage as "follow the system". The inline script reads storage first, then the system preference, then the default. The toggle button always writes storage. That asymmetry is the whole design: storage is a user override, and `matchMedia` is the default. The flash of incorrect theme comes from ignoring the system preference on first visit, and it comes from reading the preference after paint. Both are fixed by the inline script.

Keep The Recalculation Cheap

Every time the `data-theme` attribute changes, the browser has to recompute the custom property values and then re-resolve every `var()` reference that depends on them. That is a style recalculation, and it can be expensive if you have thousands of components each with several `var()` calls. The cost is not in the attribute set; it is in the cascade recomputation. The mitigation is to keep the number of custom properties small, to reference them at the point of use rather than creating a long chain of `var()` calls that reference each other, and to avoid triggering layout or paint for properties that do not need it. Background colour and text colour are paint-only, so they are cheap. Animating them with a transition is fine, because the browser can composite the colour change. Anything that changes dimensions or reflows siblings, like padding or font-size, forces a layout pass, and that is where the cost compounds. The rule: theme with colours and borders, not with spacing tokens that change width.

Fallbacks For Old Browsers

If the browser does not support custom properties, or if you have a typo in a `var()` call, the rule fails and the property falls back to its initial value, which is usually `transparent` or `auto`. The `var()` function accepts a fallback as a second argument, for example `var(--primary, #000000)`, but the fallback only applies when the custom property is not defined at all, not when it is defined with an invalid value. To guard against browsers that predate custom properties, you write a fallback rule before the `var()` rule, so the old browser uses the fallback and the new browser overrides it with the token. The `@supports` guard for custom properties is `@supports (--custom: value)`, and it tests whether the browser can parse the syntax. This is the escape hatch for users on older device-locked browsers, like iOS Safari on unsupported devices or Android WebView in apps that do not update. Those browsers will not crash; they will render the fallback, which is a light theme. If your site requires dark mode for accessibility, the fallback should be the dark palette, but that is a product decision, not a technical one.

Read The Silent Errors

The most common mistake is setting a custom property via `element.style['--name']` or `element.style.--name`, which both fail because the `CSSStyleDeclaration` object does not expose dashed identifiers as named properties. The error is a `TypeError` in the console, but many developers do not see it because they are not watching. The correct call is `element.style.setProperty('--name', 'value')`. The second most common mistake is expecting `var()` inside `url()`, like `url(var(--bg))`. That fails at parse time, and the rule is dropped silently. The workaround is to define the full `url()` as the custom property value, or to use a data URI. The third mistake is forgetting that custom properties are case-sensitive, unlike HTML attributes. `--Primary` and `--primary` are different tokens. The fourth mistake is assuming that the `data-theme` attribute on the root is inherited by children; it is not an inherited property, it is an attribute, but its selector rule makes the custom properties under it inherit. That is the pattern working as intended, not a quirk.

Your Responsibilities, And The Browser's

The cascade, inheritance, and the computed value pipeline are the browser's job, and you do not need to write JavaScript to manage them. What you must do is three things: declare the token set under each theme value, reference the tokens with `var()` in your component styles, and write the small script that sets the `data-theme` attribute. The meta `name='color-scheme'` tag and the `color-scheme` property are the fourth thing, and they are separate from the custom property theme. The browser draws the scrollbar and form controls based on `color-scheme`, and it draws your page based on the custom properties. If you forget either, the page is only half themed. The flash of incorrect theme is a fifth thing: the inline script in the head must run before the first style recalculation, and it must be synchronous. That is the complete loop, and it is the entire subject.

Start With The Script

If you take nothing else from this page, take the inline script. The `localStorage` persistence, the `matchMedia` detection, the attribute set, that is all there is to the JavaScript side. The CSS side is the custom property set and the attribute selectors. The whole architecture is small enough to hold in your head, and it is the same pattern whether you have two themes or ten. The next time you build a theme toggle, start with the inline script in the head, then write the token set, then the component styles, then the toggle button. That order prevents the flash, and the flash is the single most visible failure of a theme system. Write that script first, test it against a dark system and a light system, and then build the palettes. Everything else follows from the token set.