RizTech Academy logo
RizTech Academy
Modern CSS and WorkflowLesson 1 of 530 min

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

  1. Define --brand on :root and use it in three rules. Change it once and watch all three follow.
  2. Redefine it on a section and confirm only that section changes.
  3. Write --Brand and var(--brand) and find out why nothing happens.
  4. Build a button with --button-bg and three variants that each change one declaration.
  5. Set a custom property to an invalid value with a var() fallback, and explain why the fallback is ignored.
  6. Add an @property declaration with a syntax and initial-value, then set the invalid value again. Compare.
  7. Set inherits: false on a property and confirm a descendant no longer picks it up.
  8. Try @media (min-width: var(--bp)) and confirm it does not work.
  9. Try background: url(var(--path)), then move the whole url() into the property.
  10. Store --size: 20 and turn it into a width with calc().
  11. Transition a custom property with no @property. Then add one with syntax: "<length>" and compare.
  12. Animate a box-shadow spread through a registered property.
  13. Read --brand with element.style.getPropertyValue and then with getComputedStyle.
  14. Build a theme toggle that sets one property on <html>.
  15. Restructure your palette into the two layers and make sure no component references a layer-1 name.

Official documentation

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