Custom properties let you name a value once and reuse it everywhere. Unlike variables in a preprocessor such as Sass, they live in the browser: they cascade, inherit and can change at runtime. That makes them the foundation of themes and design systems.
Defining and using
A custom property is any property name that starts with two hyphens. Read it with var():
:root {
--colour-brand: #0b5394;
--space-m: 1.5rem;
--radius: 0.5rem;
}
.button {
background-color: var(--colour-brand);
padding: calc(var(--space-m) / 2) var(--space-m);
border-radius: var(--radius);
}
.card {
padding: var(--space-m);
border-radius: var(--radius);
}
:root matches the <html> element, so properties defined there are available to every element on the page. Change --colour-brand once and every button updates.
Names are case-sensitive: --Brand and --brand are different properties. You can spell names however you like; the property name is your own, so British spellings such as --colour-brand are fine.
They inherit and cascade
Custom properties behave like any other inherited property. You can redefine one on any element, and the new value applies to that element and its descendants:
<section class="promo">
<a class="button" href="/offer">See the offer</a>
</section>
<a class="button" href="/menu">See the menu</a>
:root {
--colour-brand: #0b5394;
}
.promo {
--colour-brand: #b42318;
}
The button inside .promo is red; the one outside stays blue. The .button rule did not change at all. This is scoping, and it is the property preprocessor variables cannot offer.
Because they cascade, custom properties also respond to state and media queries:
.button {
--button-bg: #0b5394;
background-color: var(--button-bg);
}
.button:hover {
--button-bg: #073763;
}
Fallbacks
var() accepts a fallback value after a comma, used when the property is not defined:
.alert {
color: var(--alert-text, #5c1a1a);
border-color: var(--alert-border, var(--colour-danger, red));
}
Fallbacks can nest, as in the second line. Everything after the first comma is the fallback, so var(--font, Georgia, serif) falls back to Georgia, serif.
When values are invalid
Custom properties are not checked when you define them. The browser only checks the result where it is used:
:root {
--gap: red;
}
.grid {
gap: var(--gap); /* "red" is not a valid length */
}
You might expect the browser to skip this declaration and keep an earlier gap. Instead, the declaration becomes "invalid at computed-value time", and gap falls back to its initial or inherited value, as if you had written gap: unset. Earlier declarations are not used.
Because invalid custom property values fail late, the usual "declare a fallback first" trick does not work with var(). Use the fallback argument inside var(), or @supports, instead. DevTools shows the computed value, so check the Computed tab when a variable-based style "disappears".
Custom properties and calc()
Store unitless numbers or lengths and combine them with calc():
:root {
--space-unit: 0.5rem;
}
.stack > * + * {
margin-top: calc(var(--space-unit) * 3);
}
Note that you cannot glue a unit onto a variable: var(--n)px is invalid. Write calc(var(--n) * 1px) instead.
Design tokens
Design tokens are named design decisions: colours, spacing, type sizes, radii, shadows. Custom properties are the natural way to store them in CSS. A common structure has two levels:
:root {
/* Primitive tokens: the raw palette */
--blue-600: #0b5394;
--blue-800: #073763;
--grey-50: #f7f9fb;
--grey-900: #1f2933;
--red-600: #b42318;
/* Semantic tokens: what the values are for */
--colour-text: var(--grey-900);
--colour-surface: var(--grey-50);
--colour-action: var(--blue-600);
--colour-action-hover: var(--blue-800);
--colour-danger: var(--red-600);
/* Spacing and shape */
--space-xs: 0.25rem;
--space-s: 0.5rem;
--space-m: 1rem;
--space-l: 1.5rem;
--space-xl: 2.5rem;
--radius-s: 0.25rem;
--radius-m: 0.5rem;
}
body {
color: var(--colour-text);
background-color: var(--colour-surface);
}
Components use the semantic tokens. If the brand colour changes, or you add a dark theme, you remap the semantic tokens and no component needs editing.
WordPress block themes generate custom properties from theme.json, such as --wp--preset--color--primary. The same idea applies: reference the preset variables in your CSS instead of hard-coding colours.
Theming a component
Give a component its own component-level properties with defaults, and let callers override them:
<div class="callout">Free delivery on orders over £30.</div>
<div class="callout callout-warning">Order by 2pm for next-day delivery.</div>
.callout {
--callout-accent: var(--colour-action);
--callout-bg: #eef4fb;
padding: var(--space-m) var(--space-l);
border-inline-start: 4px solid var(--callout-accent);
background-color: var(--callout-bg);
border-radius: var(--radius-m);
}
.callout-warning {
--callout-accent: #b54708;
--callout-bg: #fffaeb;
}
The variant only changes two values; the structure lives in one place. This is much easier to maintain than repeating the border and background rules per variant.
Changing values with JavaScript
Because custom properties exist at runtime, JavaScript can read and set them:
const root = document.documentElement;
root.style.setProperty('--colour-action', '#7c3aed');
const current = getComputedStyle(root).getPropertyValue('--colour-action');
console.log(current.trim());
This is handy for user-selected themes or values that follow the pointer.
Typed properties with @property
By default the browser treats custom property values as plain text. That means they cannot be animated smoothly: a transition from one colour token to another jumps. @property registers a property with a type:
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
.bar {
background: linear-gradient(to right, #0b5394 var(--progress), #d9e2ec 0);
transition: --progress 0.4s ease;
}
.bar:hover {
--progress: 75%;
}
Now the browser knows --progress is a percentage and can interpolate it. @property is supported in all current major browsers, but it is newer than custom properties themselves, so check its Baseline status if you support older versions. Without support, the value simply changes without animating.
Debugging custom properties
- In the Styles pane, hover over a
var()to see its current value. Click it to jump to where it is defined. - If a style using
var()is missing, check the Computed tab. An undefined or invalid variable makes the property fall back to its initial value. - Check spelling and the double hyphen.
var(-colour)andvar(--color)versus--colourare classic typos.
Summary
- Define custom properties with
--name: valueand read them withvar(--name). - They inherit and cascade, so redefining one on an element scopes it to that subtree.
- Use the fallback argument in
var(); invalid values fail at computed-value time. - Organise design tokens as primitives and semantic names; components use the semantic ones.
- Component-level properties make variants small;
@propertyadds types so values can animate.