Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~26 min· topic 6 of 7

Topic 3.6

Custom Properties

In one line

Custom properties (CSS variables) are properties you name yourself, like --brand, read with var(--brand, fallback); they inherit down the page like color does, so redefining one on a parent re-themes everything inside it.

Think of it like this

A school uniform rule that says "wear the house colour". The rule never names a colour. Each house sets its own colour (red house, blue house), and every student in that house follows it. Change the house colour once and every uniform in that house changes. A custom property is the "house colour", and var() is the rule that reads it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Custom property
A property you name yourself, starting with --, that holds any value for reuse.
CSS variable
The everyday name for a custom property.
var()
The function that reads a custom property: var(--name) or var(--name, fallback).
Fallback
The value var() uses when the custom property is not defined.
:root
A selector for the page's root element, <html>. Global variables usually go here.
Scope
The part of the page where a value applies: the element where it is set and everything inside it.
Design token
A named design value (a colour, a spacing step, a radius) reused across the site.
Theming
Changing the look of a whole page or section by swapping a few variable values.
Invalid at computed-value time
When a var() turns out to hold a value the property can't use; the property then acts as unset.

Step by step

01Define once, use everywhere

Put shared values on :root and read them with var(). To change the brand colour of the whole site, you edit one line.

tokens.csswhole filecss
:root {
  --brand: #2563eb;
  --text: #0f172a;
  --radius: 8px;
  --space: 1rem;
}
.button {
  background: var(--brand);
  border-radius: var(--radius);
  padding: calc(var(--space) / 2) var(--space);
}

02Override in a smaller scope

A variable set on an element wins for it and its descendants, because the nearest value is the one inherited. The .button rule doesn't change; only the variable does.

variants.csswhole filecss
.danger  { --brand: #dc2626; }
.success { --brand: #16a34a; }
/* <div class="danger"><button class="button">Delete</button></div> → red button */
Override in a smaller scopediagram
Rendering diagram…

03Theme the whole page

Name variables by their role (--bg, --text, --surface), not by their colour (--white). Then a dark theme redefines the roles. You can follow the operating system's setting with the prefers-color-scheme media query, and let a button override it with a data-theme attribute.

theme.csswhole filecss
:root { --bg: #ffffff; --text: #0f172a; --surface: #f1f5f9; }

@media (prefers-color-scheme: dark) {
  :root { --bg: #0f172a; --text: #e2e8f0; --surface: #1e293b; }
}
:root[data-theme="dark"] { --bg: #0f172a; --text: #e2e8f0; --surface: #1e293b; }

body { background: var(--bg); color: var(--text); }

04Fallbacks fill gaps, not mistakes

var(--card-bg, white) uses white when --card-bg is not set anywhere above. If --card-bg is set to 20px by mistake, the fallback is skipped and background-color becomes its initial value, transparent.

05Numbers need calc to become lengths

A unitless variable is handy because you can reuse it in several units. Multiply by 1px, 1rem or 1deg to give it a unit. Writing var(--n)px doesn't work: the result is two separate tokens, 16 and px, not 16px.

numbers.csswhole filecss
:root { --cols: 3; --gap: 12; }
.grid {
  grid-template-columns: repeat(var(--cols), 1fr);
  gap: calc(var(--gap) * 1px);      /* 12px */
  /* gap: var(--gap)px;   ✗ invalid: "12" then "px" */
}

Try it yourself

  1. 1

    Add a fourth variant

    In the first example, add <div class="warning"><button class="btn">Archive</button></div> and a rule that sets --brand to #d97706 for .warning. Predict the console line before running.

  2. 2

    Change a token globally

    In the first example, change --radius on :root to 999px. All three buttons become pills, because they all read the same token.

  3. 3

    Follow the system theme

    In the second example, add @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { ... } } with the dark values. If your computer is in dark mode, predict how the page looks on first Run.

  4. 4

    Make .b fall back properly

    In the third example, rename the variable in .b to --text-color and don't define it. Predict the colour now. The fallback green appears, because the variable is not defined at all.

Code & diagrams

One component, three themes via scope New tab

The .btn rule is the same for all three; each reads the --brand of its nearest ancestor that sets one.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Save #2563eb rgb(37, 99, 235)
Delete #dc2626 rgb(220, 38, 38)
Publish #16a34a rgb(22, 163, 74)
A light/dark theme switch New tab

Click the button. JavaScript only flips one attribute; the variables do the rest.

Preview

Press Run to see the page.

Console

Nothing logged yet.
Invalid at computed-value time: the surprise New tab

For .b, the earlier `color: red` is NOT used. The declaration became unset, so color inherits from .parent.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

a rgb(255, 0, 0)
b rgb(30, 41, 59)
c rgb(0, 128, 0)

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Gluing a unit onto a variable

With --gap: 12;, write gap: var(--gap)px;.

terminal
$ DevTools → Computed → gap
── what you'll see ──
gap is `normal` (no gap); the Styles pane shows the declaration as invalid at computed-value time.

Break #2

Typo in a variable name

Define --brand-color on :root but write background: var(--brand-colour); on the button.

terminal
$ Look at the button
── what you'll see ──
The button has no background colour (transparent) and no error appears in the console.

Myth vs fact

Myth

The var() fallback is used whenever the value doesn't work.

Fact

It is used only when the variable is not defined. If it is defined with a wrong type, the property becomes unset and the fallback is ignored.

Myth

CSS variables are the same as Sass variables.

Fact

Sass variables are replaced with values when the stylesheet is compiled and disappear. CSS custom properties live in the browser, inherit through the page, can change per element, in media queries, and from JavaScript at runtime.

Myth

Custom properties must be defined on :root.

Fact

Any rule can set them. :root is only the place for page-wide values; components can define and override their own.

Myth

You can use a variable in a media query: @media (min-width: var(--bp)).

Fact

var() only works in property values. Media query conditions are evaluated without an element, so they can't read custom properties.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Unregistered custom properties inherit and are not animatable (they flip halfway). **@property** registers one with a type, inheritance and an initial value, for example @property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }. Registered properties are type-checked, can be animated and transitioned, and are Baseline since 2024.

  • ▸

    Because custom properties inherit, changing one on :root makes the browser recompute styles for every element that inherits it, which on huge pages is measurable. For values that change every frame (pointer position, scroll), set the variable on the smallest element that needs it, or register it with inherits: false.

  • ▸

    The light-dark(lightValue, darkValue) function (Baseline 2024) picks a value based on the element's color-scheme. Combined with :root { color-scheme: light dark; } it can replace a duplicated dark-theme block for simple colour pairs.

  • ▸

    getPropertyValue() returns the variable's specified tokens with leading and trailing white space trimmed (older browsers kept a leading space). It doesn't resolve them, so --x: calc(1px + 2px) reads back as the text calc(1px + 2px) unless the property is registered with a type.

Remember this

  1. 1

    A custom property is a property whose name starts with two dashes: --brand: #2563eb;. You set it like any declaration, inside a rule. You read it anywhere a value is allowed with **var()**: color: var(--brand);. People often call them CSS variables. Names are case-sensitive: --Brand and --brand are two different properties.

  2. 2

    Custom properties are inherited: a value set on an element is visible to all its descendants (children, grandchildren…) unless one of them sets its own. That is why global design tokens go on **:root** (the <html> element): every element inherits them. A design token is a named design decision, like --space-2: 0.5rem or --radius: 8px, used everywhere instead of repeating raw values.

  3. 3

    Scope means where a value applies. Setting --brand inside .danger { --brand: #dc2626; } changes it only for .danger and what's inside it. Components can read var(--brand) and automatically pick up whichever value is closest above them. This is the engine of theming: switch [data-theme="dark"] on <html>, redefine a handful of variables there, and the whole page changes colour without touching the component rules.

  4. 4

    **var(--name, fallback) uses the fallback when the variable is not defined** on the element (or is the special "guaranteed-invalid" value). Everything after the first comma is the fallback, so var(--font, Inter, sans-serif) works. Fallbacks can nest: var(--a, var(--b, 16px)). The fallback does not kick in when the variable exists but holds a value that doesn't fit the property.

  5. 5

    That last case has a name: invalid at computed-value time. The browser can't check color: var(--size) while reading the CSS, because it doesn't know --size yet. If --size later turns out to be 20px, the declaration is invalid, and instead of falling back to an earlier color rule, the property becomes **unset**: inherited properties like color inherit from the parent, others go to their initial value. This surprises everyone once; the live example below shows it.

  6. 6

    Variables hold tokens, not types: --gap: 16 is just the characters 16. Turn a number into a length with calc(var(--gap) * 1px) (Topic 3.7). You can't use var() in a property name, a selector, or a media query condition. JavaScript reads and writes them with getComputedStyle(el).getPropertyValue("--brand") and el.style.setProperty("--brand", "red"), which is how theme switchers work (JavaScript course, Topic 8.5).

Explain it without notes

01

What is a custom property and how do you read it?

02

Explain how scope and inheritance make custom properties good for theming.

03

When is the fallback in var(--x, fallback) used, and when is it not?

04

What does "invalid at computed-value time" mean, and what value does the property get?

05

How do CSS custom properties differ from Sass variables?

Practice

01

Create tokens --space-1 (0.25rem), --space-2 (0.5rem) and --space-4 (1rem) on :root and use them for a .card's padding (space-4) and gap between its children (space-2).

02

Make an .alert component whose border and text use --alert-color, defaulting to blue when not set, and an .alert.error variant that sets it to red.

03

Write a JavaScript line that reads --brand from the root, and one that sets it to #7c3aed.

Trade-offs

  • ↔

    Variables make themes and variants cheap to build, but add a layer of indirection: to know a colour you have to find where its variable was last set (DevTools' Computed pane helps).

  • ↔

    Unitless number variables are flexible but need calc() everywhere; variables with units are simpler but less reusable.

Done when you can

  • I can define a custom property and read it with var() and a fallback.

  • I can override a variable in a smaller scope to make a variant.

  • I can build a light/dark theme by redefining role-based variables.

  • I can explain "invalid at computed-value time" and predict its result.

  • I can turn a unitless variable into a length with calc().

  • I can read and set a variable from JavaScript.