Custom properties and theming
You have been using custom properties since module 2 — the colour palette, the spacing scale, the media-query trick in module 4. This lesson is what they actually are, because they behave unlike anything else in CSS and the differences are where the useful tricks live.
They are not variables
:root {
--brand: #1a4d2e;
--gap: 1rem;
}
.card {
background: var(--brand);
padding: var(--gap);
}
That looks like a variable and it is not. A Sass variable is substituted at compile time and then gone. A custom property is a real CSS property — it participates in the cascade, it inherits, it can be redefined per element, and it can be changed at runtime.
Which produces the three things that matter:
They inherit. A value set on .card applies to everything inside it.
They cascade. A later or more specific rule overrides them, like any property.
They are live. Change one and every var() using it updates immediately, with no
recompilation and no JavaScript touching individual rules.
The syntax
--my-prop: value; /* two hyphens, case-sensitive */
var(--my-prop) /* to use it */
var(--my-prop, 1rem) /* with a fallback */
var(--a, var(--b, 1rem)) /* nested fallbacks */
Case matters. --Brand and --brand are different properties, which is not true of
normal CSS.
The fallback is the second argument, and it is used when the property is not defined — not when it is invalid. That distinction bites, and the invalid case is below.
Scoping: the part that makes them powerful
Define on :root for global values, and redefine on a component for local ones:
.button {
--button-bg: var(--brand);
--button-fg: white;
background: var(--button-bg);
color: var(--button-fg);
border: 1px solid var(--button-bg);
}
.button--danger {
--button-bg: #b3261e; /* one line, and everything follows */
}
.button--ghost {
--button-bg: transparent;
--button-fg: var(--brand);
}
A variant is one declaration, not a re-statement of every property. That is the pattern worth taking from this lesson: expose a component's adjustable parts as custom properties, and variants become a list of values rather than a list of overrides.
And because they inherit, a section can retheme everything inside it:
.panel--dark {
--text: #e8eaea;
--bg: #14181a;
--border: #2f3a3d;
}
Every descendant using those tokens flips, with no descendant selectors at all.
:root versus html
:root { --brand: #1a4d2e; } /* specificity 0,1,0 */
html { --brand: #1a4d2e; } /* specificity 0,0,1 */
The same element. :root is a pseudo-class so it is slightly more specific, and it is the
convention — use it, and be aware they are the same thing when you meet html in
somebody else's code.
Where they behave unlike normal CSS
Four things, and each is a real gotcha.
They are inherited, so a global change can leak
:root { --gap: 1rem; }
.tight { --gap: 0.25rem; } /* also affects every descendant using --gap */
Usually what you want. Occasionally not — a --gap set for a card's padding also reaching
a nested list's gap is a surprise. Name them for their job (--card-padding, not --gap)
when a component is nested.
To stop inheritance deliberately:
.card { --gap: initial; } /* the guaranteed-invalid value */
An invalid value does not fall back — it becomes unset
This is the one that costs the most time:
:root { --gap: 1rem; }
.broken { --gap: red; } /* not a length */
.broken { padding: var(--gap, 2rem); }
You might expect 2rem. You get nothing — padding becomes its inherited-or-initial
value, which is 0.
The reason: the fallback applies when the property is undefined. Here it is defined, as
red. The substitution happens first, the result padding: red is invalid at computed
value time, and the property is treated as unset. Nothing in devtools is struck
through in the usual way, which makes it genuinely hard to spot.
The defence is @property, below.
They cannot be used everywhere
.a { width: var(--w); } /* fine */
@media (min-width: var(--bp)) { } /* does NOT work */
.b { --sel: ".card"; } var(--sel) { } /* not a thing */
.c { background: url(var(--path)); } /* does NOT work */
Custom properties are substituted too late for media queries, selectors and url().
Module 4 covered the media-query limitation; the url() one catches people building icon
systems. The workaround for url() is to put the whole url(...) in the property:
.icon { --icon: url("/icons/tick.svg"); background-image: var(--icon); }
Concatenation does not work as you expect
.a { --size: 20; width: var(--size)px; } /* invalid */
.b { --size: 20; width: calc(var(--size) * 1px); } /* correct */
A unitless number becomes a length by multiplying. This is a genuinely useful idiom — store the number, derive the unit — and it is the only way to do it.
@property: types, defaults and animation
The modern addition, and it fixes two problems at once:
@property --card-gap {
syntax: "<length>";
inherits: false;
initial-value: 1rem;
}
@property --accent {
syntax: "<color>";
inherits: true;
initial-value: #1a4d2e;
}
Three things you get:
Type checking. An invalid value is now rejected and the property keeps its
initial-value, instead of the whole declaration collapsing to unset. That is the
previous gotcha, solved.
Control over inheritance. inherits: false stops the leak described above without
resetting anything.
Animation. This is the big one. A plain custom property is a string as far as the
animation engine is concerned, so it jumps from one value to the next. Declared with a
syntax, the browser knows how to interpolate it:
@property --shadow-spread {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.card {
box-shadow: 0 2px var(--shadow-spread) rgb(0 0 0 / 0.15);
transition: --shadow-spread 200ms;
}
.card:hover { --shadow-spread: 16px; }
Without @property that transition does nothing. With it, it animates. Gradients,
multi-part shadows and any composite value become animatable this way, which was not
possible in CSS at all before.
Supported in all current browsers.
Reading and writing from JavaScript
The live-ness, used deliberately:
const root = document.documentElement;
getComputedStyle(root).getPropertyValue("--brand").trim(); // read
root.style.setProperty("--brand", "#0f3d20"); // write
root.style.removeProperty("--brand"); // back to the stylesheet
Note getComputedStyle, not style. Reading element.style.getPropertyValue only
sees inline values, so a property from your stylesheet reads as an empty string — which is
a common confusion.
And note the .trim(): the returned value keeps its leading whitespace from the
stylesheet.
This is how a theme toggle works, and it is the pattern module 4 described: set one
property, and every rule that uses it follows. One setProperty call instead of touching
forty rules — which is also far cheaper, because the browser recalculates styles once.
Naming
Two-layer naming is the convention worth adopting:
:root {
/* layer 1: the raw palette. Named for what they ARE */
--green-900: #0f3d20;
--green-700: #1a4d2e;
--grey-100: #f7f7f7;
/* layer 2: semantic tokens. Named for what they DO */
--colour-text: #1f1f1f;
--colour-surface: var(--grey-100);
--colour-accent: var(--green-700);
--colour-accent-hover: var(--green-900);
}
Components use layer 2 only. Then dark mode redefines layer 2 and nothing else, which
is exactly what module 4's dark-mode lesson did — and it is why --colour-accent rather
than --green-700 appearing in a component matters.
The naming rule from the best-practices lessons applies: name it for its job, not its
value. --colour-accent survives a rebrand; --green-700 used directly in a component
does not.
Check your work
Why they are not variables. They are real properties — they cascade, inherit, and are live at runtime.
Whether case matters. Yes, unlike normal CSS.
When the var() fallback is used. When the property is undefined, not when its value
is invalid.
What an invalid value produces. unset for the whole declaration — so padding
becomes 0, and the fallback is ignored.
What a component variant should cost. One declaration, because the adjustable parts are exposed as properties.
Where custom properties cannot be used. Media queries, selectors, and inside url().
How to attach a unit to a stored number. calc(var(--n) * 1px).
Three things @property gives you. Type checking (so invalid values are rejected),
control over inheritance, and animation.
Why a custom property does not animate by default. It is a string until a syntax is
declared.
Which JavaScript getter to use. getComputedStyle, not element.style — and .trim()
the result.
The two-layer naming convention. A raw palette named for what colours are, semantic tokens named for what they do; components use only the second.
Practice
- Define
--brandon:rootand use it in three rules. Change it once and watch all three follow. - Redefine it on a section and confirm only that section changes.
- Write
--Brandandvar(--brand)and find out why nothing happens. - Build a button with
--button-bgand three variants that each change one declaration. - Set a custom property to an invalid value with a
var()fallback, and explain why the fallback is ignored. - Add an
@propertydeclaration with asyntaxandinitial-value, then set the invalid value again. Compare. - Set
inherits: falseon a property and confirm a descendant no longer picks it up. - Try
@media (min-width: var(--bp))and confirm it does not work. - Try
background: url(var(--path)), then move the wholeurl()into the property. - Store
--size: 20and turn it into a width withcalc(). - Transition a custom property with no
@property. Then add one withsyntax: "<length>"and compare. - Animate a
box-shadowspread through a registered property. - Read
--brandwithelement.style.getPropertyValueand then withgetComputedStyle. - Build a theme toggle that sets one property on
<html>. - Restructure your palette into the two layers and make sure no component references a layer-1 name.
Official documentation
- MDN — Using CSS custom properties — The cascade and inheritance behaviour, and the invalid-value rule.
- MDN — var() — Fallbacks, and exactly when they apply.
- MDN — @property —
syntax,inheritsandinitial-value, with the animation consequence. - W3C — CSS Custom Properties for Cascading Variables Level 1 — The specification, including "invalid at computed-value time".
Next: the selectors that replaced a lot of JavaScript.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship