Custom properties and theming
Skip this page if you already know…
- that
--my-colouris a real CSS property, and that it inherits; - the difference between
var(--x)andvar(--x, fallback); - why overriding on a subtree changes only that part of the screen;
- why a custom property beats a preprocessor variable.
The problem
An app with the brand colour written into forty files:
.button {
background: #3b82f6;
}
.link {
color: #3b82f6;
}
.badge {
border-color: #3b82f6;
}
Changing the brand is forty edits. Supporting a dark theme is forty pairs of edits. And a customer who wants their own colour is impossible without a rebuild.
The fix is one line
A custom property is a CSS property like any other — the name just starts with --
and the value is yours:
:root {
--brand: #3b82f6;
}
.button {
background: var(--brand);
}
Now the brand lives in one place. But what makes this the entire theming strategy is not the centralisation — it is the inheritance.
A complete example
Single file. Open it in a browser: both cards are the same CSS, and one of them is dark.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Custom properties</title>
<style>
:root {
--bg: #ffffff;
--text: #101828;
--brand: #3b82f6;
--radius: 8px;
}
[data-theme="dark"] {
--bg: #0b0d12;
--text: #f1f3f8;
--brand: #7aa2ff;
}
.card {
background: var(--bg);
color: var(--text);
border: 1px solid var(--brand);
border-radius: var(--radius);
padding: 16px;
margin-bottom: 12px;
font-family: system-ui, sans-serif;
}
.card button {
background: var(--brand);
color: var(--bg);
border: 0;
border-radius: var(--radius);
padding: 8px 16px;
}
</style>
</head>
<body>
<div class="card">
<p>Light — uses the values from <code>:root</code>.</p>
<button type="button">Action</button>
</div>
<div class="card" data-theme="dark">
<p>Dark — same rules, values overridden right here.</p>
<button type="button">Action</button>
</div>
</body>
</html>
Not one .card rule was duplicated. The second card redefines three values on
itself, and everything below it — including the <button>, styled by the
.card button rule — starts resolving var(--brand) to the new value.
Why this works
Custom properties inherit. var(--brand) is not "read the global variable": it is
"read the value of --brand on this element", and if this element did not
define it, the value comes from the parent, and its parent, up to :root.
That chain is what makes subtree theming possible. And it is why a Sass variable
does not do the same: $brand is resolved at build time and disappears from
the CSS. A custom property exists at runtime — JS can read and write it.
// read
getComputedStyle(document.documentElement).getPropertyValue("--brand");
// write
document.documentElement.style.setProperty("--brand", "#e11d48");
var() with a fallback
The second argument is the value used when the property is not defined:
.card {
padding: var(--card-padding, 16px);
}
That creates a knob: consumers may define --card-padding to change the
spacing, and those who do not get 16px. Unlike a token — a named design decision
that is always defined — a knob exists precisely to be optional.
The SDK's CSS linter treats the two differently
tempest doctor reports var(--does-not-exist) without a fallback as a
missing token, and never reports var(--x, fallback). That rule came out of
dogfooding: the first version of the analysis flagged 47 problems in the SDK's
own CSS, and 43 of them were the knob idiom. The false-positive rate decides
whether a tool gets read or ignored.
Where it shows up in the SDK
This is the entire theming strategy of tempest-react-sdk. There is no colour
prop, no JS theme object, no provider required for styling: there are --tempest-*
tokens defined on :root and redefined under [data-tempest-theme="dark"].
:root {
--tempest-bg: #ffffff;
--tempest-surface: var(--tempest-gray-50);
--tempest-text: var(--tempest-gray-900);
--tempest-primary: var(--tempest-primary-500);
}
[data-tempest-theme="dark"] {
--tempest-bg: #0b0d12;
--tempest-text: #f1f3f8;
}
Customising the whole app means overriding in your own CSS, imported afterwards:
:root {
--tempest-primary: #e11d48;
--tempest-radius-md: 4px;
}
Done — every <Button>, <Badge>, <Input> and the rest repaint. You touched no
selector, so there is no specificity fight (CSS: cascade).
Dark by attribute, not by class
The SDK uses data-tempest-theme="dark", not class="dark". The reason is this
page's example: because the attribute can sit on any element, you can darken
a subtree — a preview, a drawer, a panel — without darkening the page. A global
class would not give you that for free.
<ThemeProvider> manages the attribute, and themeInitScript applies it
before the first paint so the page does not flash white. Details in
Theme.
A text token validated against one background does not hold over another
--tempest-text-subtle is resolved against --tempest-bg and
--tempest-surface, and it fails 4.5:1 over --tempest-primary-soft. Over a
tinted surface, use that surface's foreground (--tempest-primary-on-soft) and
de-emphasise by size, not colour. This happened twice in the SDK and both
times it only showed up in a real browser — axe in jsdom disables the contrast
check because nothing is painted.
Recap
- A custom property is a real CSS property:
--xinherits from the parent up to:root. ✅ - Overriding on a subtree changes only that part of the screen — that is what makes regional theming possible.
- It exists at runtime; JS reads it with
getPropertyValueand writes it withsetProperty. A Sass variable disappears at build time. var(--x, fallback)is an optional knob;var(--x)is a token that should exist.- In the SDK:
--tempest-*tokens on:root, dark theme under[data-tempest-theme="dark"], and customising means overriding a token — never fighting a selector.
📚 Canonical reference: MDN — Custom properties
➡️ Next page: JavaScript: value, reference, scope