Name a value once
Look at any stylesheet and the same few values appear again and again: the brand colour, the spacing unit, the corner radius. Changing the brand colour means finding and editing every copy. CSS custom properties, often called CSS variables, solve this: store a value once under a name, use it everywhere, and change it in one place.
Declare and use
A custom property is any name starting with two dashes. You declare it like a normal property and read it with var():
<style>
:root {
--brand: #4f46e5;
--radius: 10px;
--space: 1rem;
}
.btn {
background: var(--brand);
color: white;
padding: var(--space) calc(var(--space) * 2);
border: 0;
border-radius: var(--radius);
}
.card {
border: 2px solid var(--brand);
border-radius: var(--radius);
padding: var(--space);
margin-bottom: var(--space);
}
</style>
<div class="card">A card using the brand colour.</div>
<button class="btn">A button using it too</button>Change --brand in the first rule and both elements update. Names are case-sensitive and entirely up to you: --color-primary, --text, --space-4.
:root and scope
:root selects the top element (html), so variables defined there are global: every element inherits them. You can also declare variables on any element, which makes them local to that element and its descendants:
<style>
:root { --accent: #2563eb; }
.warning { --accent: #dc2626; } /* overrides inside .warning */
.note { border-left: 6px solid var(--accent); padding: 8px 12px; margin: 8px 0; background: #f8fafc; }
</style>
<div class="note">Normal note (blue)</div>
<div class="warning"><div class="note">Warning note (red): same CSS, new value</div></div>The .note rule never changed. It just receives a different --accent depending on where it sits. This is how components become themeable.
Fallbacks
var() takes an optional fallback after a comma, used when the variable is undefined:
color: var(--heading-color, #111827);
padding: var(--pad, 1rem 1.5rem);Theming and dark mode
Because every rule reads from variables, a theme is just a different set of values:
<style>
:root { --bg: #ffffff; --text: #111827; --card: #f1f5f9; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0f172a; --text: #e2e8f0; --card: #1e293b; }
}
body { background: var(--bg); color: var(--text); font-family: system-ui, sans-serif; padding: 16px; }
.card { background: var(--card); padding: 16px; border-radius: 10px; }
</style>
<div class="card">This follows your system's light or dark setting.</div>For a manual toggle, put the dark values under a class or attribute, [data-theme="dark"] { … }, and switch that attribute with a small script. Always keep the contrast accessible in both themes.
Variables with calc() and other functions
:root { --space: 8px; }
.stack > * + * { margin-top: calc(var(--space) * 3); }
.box { width: min(100% - 2rem, 60rem); }You can store the number and do the maths where it is used. Storing parts of colours also works: --hue: 250; background: hsl(var(--hue) 80% 50%); lets you derive a whole palette from a single number.
Using variables in components
A clean pattern is to expose a few local variables that callers can override:
.btn {
--btn-bg: var(--brand);
background: var(--btn-bg);
}
.btn.danger { --btn-bg: #dc2626; }Changing variables with JavaScript
Variables exist in the browser, so scripts can change them live:
document.documentElement.style.setProperty('--brand', '#059669');
const current = getComputedStyle(document.documentElement).getPropertyValue('--brand');That is something Sass-style compile-time variables cannot do.
Rules and limits
- Names are case-sensitive:
--Brandand--branddiffer. - A variable is only usable as a value, never as a property name or inside a selector or media query condition.
- Invalid values are not detected until
var()is used; if a variable is invalid at that point, the property falls back to its initial value. - Variables inherit, so a value set on a parent flows to children.
- Browser support is universal in modern browsers.
Organising variables
Group them by purpose and keep names meaningful:
:root {
/* colour */
--color-text: #111827;
--color-brand: #4f46e5;
/* spacing scale */
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 1rem; --space-4: 2rem;
/* shape and depth */
--radius: 10px;
--shadow-1: 0 1px 3px rgb(0 0 0 / .15);
}Name variables for their role (--color-text) more than their appearance (--dark-grey), so the theme can change without the names lying.
Common mistakes
- Forgetting the two dashes, or writing
--brandwhere you needvar(--brand). - Declaring on the wrong element, so the variable is not inherited where you use it.
- Names tied to looks (
--blue) that stop making sense after a rebrand. - No fallback for variables set by scripts.
- Dark themes with poor contrast.
Exercise
Define colour and spacing variables on :root, build a card and a button from them, and create a .compact class that halves the spacing for anything inside it without editing the card or button rules.
Show one possible answer
<style>
:root { --brand: #7c3aed; --space: 1rem; --radius: 10px; }
.compact { --space: 0.5rem; }
.card { padding: var(--space); border: 2px solid var(--brand); border-radius: var(--radius); margin-bottom: var(--space); }
.btn { padding: var(--space) calc(var(--space) * 2); background: var(--brand); color: white; border: 0; border-radius: var(--radius); }
</style>
<div class="card">Normal card <button class="btn">Go</button></div>
<div class="compact"><div class="card">Compact card <button class="btn">Go</button></div></div>Key points
- A custom property starts with two dashes, is declared like any property, and is read with var().
- Declaring variables on :root makes them available everywhere, and declaring them on a component limits their scope.
- Variables inherit and can be changed by media queries, so a dark theme means redefining a few values.
- var() accepts a fallback, used when the variable is not defined.
- Unlike preprocessor variables, CSS variables live in the browser and can be changed at runtime.