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)orvar(--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 asunset.
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.
: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.
.danger { --brand: #dc2626; }
.success { --brand: #16a34a; }
/* <div class="danger"><button class="button">Delete</button></div> → red button */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.
: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.
: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
Add a fourth variant
In the first example, add
<div class="warning"><button class="btn">Archive</button></div>and a rule that sets--brandto#d97706for.warning. Predict the console line before running. - 2
Change a token globally
In the first example, change
--radiuson:rootto999px. All three buttons become pills, because they all read the same token. - 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
Make .b fall back properly
In the third example, rename the variable in
.bto--text-colorand don't define it. Predict the colour now. The fallbackgreenappears, because the variable is not defined at all.
Code & diagrams
The .btn rule is the same for all three; each reads the --brand of its nearest ancestor that sets one.
Preview
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)Click the button. JavaScript only flips one attribute; the variables do the rest.
Preview
Console
Nothing logged yet.For .b, the earlier `color: red` is NOT used. The declaration became unset, so color inherits from .parent.
Preview
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;.
Break #2
Typo in a variable name
Define --brand-color on :root but write background: var(--brand-colour); on the button.
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
:rootmakes 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 withinherits: false. - ▸
The
light-dark(lightValue, darkValue)function (Baseline 2024) picks a value based on the element'scolor-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 textcalc(1px + 2px)unless the property is registered with a type.
Remember this
- 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:--Brandand--brandare two different properties. - 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.5remor--radius: 8px, used everywhere instead of repeating raw values. - 3
Scope means where a value applies. Setting
--brandinside.danger { --brand: #dc2626; }changes it only for.dangerand what's inside it. Components can readvar(--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
**
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, sovar(--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
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--sizeyet. If--sizelater turns out to be20px, the declaration is invalid, and instead of falling back to an earliercolorrule, the property becomes **unset**: inherited properties likecolorinherit from the parent, others go to their initial value. This surprises everyone once; the live example below shows it. - 6
Variables hold tokens, not types:
--gap: 16is just the characters16. Turn a number into a length withcalc(var(--gap) * 1px)(Topic 3.7). You can't usevar()in a property name, a selector, or a media query condition. JavaScript reads and writes them withgetComputedStyle(el).getPropertyValue("--brand")andel.style.setProperty("--brand", "red"), which is how theme switchers work (JavaScript course, Topic 8.5).
Explain it without notes
What is a custom property and how do you read it?
Explain how scope and inheritance make custom properties good for theming.
When is the fallback in var(--x, fallback) used, and when is it not?
What does "invalid at computed-value time" mean, and what value does the property get?
How do CSS custom properties differ from Sass variables?
Practice
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).
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.
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.