CSS Variables for Theming: The Runtime Mechanics
Custom properties make light/dark theming and brand switching a runtime concern instead of a build step — here's the cascade behavior that makes it work, and the flash-of-wrong-theme bug everyone hits once.
Before CSS custom properties, theming a site meant either shipping two full stylesheets and swapping which one loaded, or running a preprocessor build step per theme. Custom properties (--variable-name syntax) turned theming into something the browser resolves live, at render time, which is a genuinely different and more flexible mechanism than either of those older approaches — and it comes with its own specific gotchas worth knowing before you rely on it in production.
The core mechanism: custom properties inherit through the cascade
:root {
--color-bg: #ffffff;
--color-text: #111111;
}
[data-theme="dark"] {
--color-bg: #111111;
--color-text: #f5f5f5;
}
body {
background: var(--color-bg);
color: var(--color-text);
}The mechanism that makes this work is ordinary CSS cascade and inheritance — --color-bg is just a property like any other, and it inherits down the DOM tree the same way color or font-family does. Setting data-theme="dark" on any ancestor element (commonly <html> or <body>) redefines the custom properties for everything beneath it, and every var() reference re-resolves automatically — no JavaScript re-render, no rebuilt stylesheet, just the browser recomputing cascaded values the same way it always has for any other inherited property.
The flash-of-wrong-theme bug, and why it happens
The most common bug teams hit the first time they ship dark-mode theming this way: a brief flash of the light theme before the dark theme applies, visible especially on a slow connection or a large page. This happens because the theme attribute is usually set by a small JavaScript snippet reading a stored preference (localStorage, or a cookie) — and if that script runs after the browser has already started painting with the default :root values, there's a real, visible gap between first paint and the theme being applied.
The standard fix is running the theme-detection script as early as possible, before any visible content paints — typically an inline <script> placed directly in <head>, executed synchronously before the rest of the page renders, that reads the stored preference and sets data-theme on <html> immediately:
<script>
(function () {
var stored = localStorage.getItem("theme");
var theme = stored || (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
})();
</script>Running this synchronously and early — before any CSS that depends on data-theme has a chance to paint with default values — is what actually eliminates the flash; deferring it to a normal DOMContentLoaded-triggered script reintroduces the bug because the browser has already painted once by then.
Respecting the system preference as the default, and letting users override it
prefers-color-scheme is a media feature the browser exposes based on the OS-level light/dark setting, and it's the correct default for any theme that doesn't have an explicit stored user override yet:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--color-bg: #111111;
--color-text: #f5f5f5;
}
}The :not([data-theme="light"]) guard is what lets an explicit user choice (stored and applied via the JavaScript above) override the system preference — without it, a user who explicitly picked light mode on a dark-mode OS would see their choice silently ignored the moment the media query took precedence, which is a genuine and avoidable usability bug.
Layering a token system on top of this mechanism
Custom properties are the delivery mechanism; the actual token architecture riding on top of them is what determines whether theming stays maintainable as a product grows. The two-tier primitive/semantic pattern covered in building a design token color system maps directly onto this: primitives (--blue-500) stay constant across themes, while semantics (--color-action-primary) get redefined per theme scope, exactly as --color-bg/--color-text are redefined above. This is also where OKLCH earns real practical value again — generating both a light-theme and dark-theme mapping from the same underlying primitive ramp (rather than hand-picking two unrelated sets of colors) keeps the two themes visually consistent in a way ad hoc dark-mode colors often aren't; see dark mode color palette guide for the surface-elevation rules specific to the dark side of that mapping.
Generating a starter set instead of hand-writing every variable
Hand-writing dozens of custom property declarations for a full primitive-plus-semantic token set is tedious and error-prone to keep in sync as a palette evolves. The CSS variable exporter generates a ready-to-paste :root block (and a matching dark-theme scope) from a palette built with the shades, tints & tones generator or picked directly with the color picker, which is a faster and less error-prone starting point than transcribing hex values into variable declarations by hand.
Custom properties versus a preprocessor's variables — a genuinely different mechanism
It's worth being explicit about why this is a meaningfully different tool from Sass or Less variables, not just a syntax swap. Preprocessor variables are resolved entirely at build time — by the time a stylesheet ships to the browser, every \$variable\ reference has already been replaced with its literal value, and the concept of "the variable" no longer exists at runtime at all. Custom properties are resolved live, in the browser, every time the cascade is computed — which is precisely what makes runtime theme-switching possible without a rebuild or a second stylesheet. The tradeoff is that custom properties can't do the kind of compile-time math and control-flow a preprocessor can (loops, conditionals, string manipulation) — many real projects use both together, preprocessor variables for build-time constants and structural logic, custom properties specifically for anything that needs to change at runtime, like theme.
Scoping custom properties beyond just light/dark
The same mechanism that powers light/dark theming works for any runtime-scoped variation, not just a global theme toggle. A component that needs a locally different accent color — say, a notification banner whose color depends on severity (info, warning, error) — can redefine \--color-accent\ on that specific component's wrapper element rather than only ever at \:root\, and everything inside inherits the locally-scoped value automatically:
\\\css .banner[data-severity="error"] { --color-accent: var(--color-error-500); } .banner { border-left: 4px solid var(--color-accent); } \\\
This is the same cascade-and-inheritance mechanism as the page-level theme example above, just scoped to a smaller subtree — a useful pattern once a token system has more than one axis of variation (theme, severity, brand, density) rather than only light/dark.