Custom properties are runtime variables, and that changes theming
There are two kinds of “CSS variable” and the difference is when they exist. A
Sass variable ($brand) is a build-time value: the preprocessor substitutes
it and it is gone before the browser ever sees the CSS. A CSS custom property
(--brand) is a runtime value: it lives in the cascade, it inherits down the
tree, and — the crucial part — you can change it while the page is running and
every rule that reads it updates instantly. That single property, being alive at
runtime, is why modern theming, dark mode, and per-component overrides are built on
custom properties and not on preprocessor variables.
Define once, read everywhere, change at runtime
Declare custom properties on :root so they inherit to the whole document, then
read them with var(). Because they are live, reassigning one on any element
updates every rule that reads it under that element:
:root {
--brand: #fe854c;
--text: #1a1a1a;
--bg: #ffffff;
}
.button { background: var(--brand); }
.page { color: var(--text); background: var(--bg); }
Nothing here is compiled away — --brand is sitting in the running page, and the
button’s colour is a live reference to it.
Theming is a one-selector override
Because custom properties cascade and inherit, a theme is just a different set of values scoped to a selector. Dark mode does not touch a single component rule — it redefines the tokens, and everything downstream re-resolves:
[data-theme="dark"] {
--text: #e6e6e6;
--bg: #161b22;
--brand: #ff9a63;
}
/* no component CSS changes — .button and .page just read the new values */
Flip the attribute in one line of JS and the whole page rethemes, with no per-component logic:
document.documentElement.dataset.theme = "dark"; // every var() re-resolves
Read from JS, and scope per component
The runtime nature cuts both ways: JavaScript can read and write custom
properties too, which is how you bridge dynamic values into CSS (a drag position, a
computed accent) without inline-styling every rule. And because they inherit, you
can override a token for one subtree — a card that wants a denser spacing scale sets
--space: 4px on itself and its children pick it up, without new class names. Two
caveats keep it honest: custom properties are not known to the preprocessor, so you
cannot use them in Sass math at build time; and they resolve at use time, so a
typo’d var(--brnad) silently falls back rather than erroring. Used well, they turn
theming from a rebuild into a runtime toggle — which is exactly what the theme-toggle
exercise builds: one attribute flip, a whole palette redefined, zero component
edits.