CSS Variables and Custom Properties
A practical guide to CSS custom properties — declaring them, using var() with fallbacks, scoping by the cascade, building a light/dark theme, and updating them dynamically from JavaScript.
What you'll learn
- ✓How to declare and read CSS custom properties
- ✓var() with fallbacks and the scoping rules of the cascade
- ✓Building a light/dark theme entirely in CSS
- ✓Updating custom properties dynamically from JavaScript
- ✓Common pitfalls — invalid values, type coercion, and inheritance surprises
Prerequisites
- •Comfort with selectors and the cascade — see CSS Selectors and the Cascade
- •You have written CSS using Sass or PostCSS variables at least once
For years, CSS borrowed its “variables” from preprocessors like Sass. Those values were resolved at build time and disappeared from the final stylesheet. CSS custom properties — what most people now mean when they say “CSS variables” — are a native browser feature that lives in the runtime, respects the cascade, and can be updated from JavaScript. They are the foundation of modern theming.
Declaring and reading
A custom property is any property whose name begins with two dashes:
:root {
--brand-color: #2563eb;
--space-md: 1rem;
}
You read it with var():
.button {
background: var(--brand-color);
padding: var(--space-md);
}
The leading -- is part of the name. You write it both when declaring and when reading. The :root selector matches the <html> element and is the conventional place to put globals — but custom properties can be declared on any selector.
Fallbacks
var() accepts a second argument used when the property is undefined or invalid:
.card {
background: var(--card-bg, white);
border: 1px solid var(--card-border, #e5e7eb);
}
The fallback can itself reference another custom property:
color: var(--text-color, var(--default-text, black));
This is useful for progressive enhancement: ship a sensible default, let consumers override it by defining the variable.
Scoping via the cascade
The single most important thing to understand about custom properties: they cascade and inherit like any other CSS property. A property declared on an element is available to that element and all of its descendants.
.card {
--padding: 1rem;
padding: var(--padding);
}
.card.large {
--padding: 2rem; /* only large cards get this */
}
You can scope a whole design token to a component:
.alert {
--alert-bg: #fef3c7;
--alert-fg: #92400e;
background: var(--alert-bg);
color: var(--alert-fg);
}
.alert.error {
--alert-bg: #fee2e2;
--alert-fg: #991b1b;
}
The error variant reuses the same background and color rules — only the variables change. This is how mature component CSS scales.
Try it yourself. Create a .button class that uses --bg and --fg for its colours. Then create .button.primary, .button.danger, and .button.ghost that only redefine the two variables. Confirm all four variants render correctly without writing new background: or color: rules.
Building a light/dark theme
This is where custom properties earn their keep. Define the same set of token names twice — once for light, once for dark — and toggle a class on <html> or <body>.
:root {
--bg: #ffffff;
--fg: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--accent: #2563eb;
}
:root.dark {
--bg: #0b1020;
--fg: #e5e7eb;
--muted: #9ca3af;
--border: #1f2937;
--accent: #60a5fa;
}
body {
background: var(--bg);
color: var(--fg);
}
.card {
background: var(--bg);
border: 1px solid var(--border);
}
To switch themes, you toggle one class:
document.documentElement.classList.toggle('dark');
The entire interface re-skins instantly. No component CSS needs to know light from dark — components reference the tokens, and the tokens change with the class.
Respecting the OS preference
You can wire the same variables to the user’s system preference:
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg: #0b1020;
--fg: #e5e7eb;
/* ... */
}
}
This says: “use dark unless the user explicitly opted into light.” A nice pattern is to combine the media query with a manual override class so users can pick.
Type-aware values: numbers and units
A custom property is just a token string. CSS does not know it represents a length, a colour, or anything else until it is substituted.
:root {
--radius: 8; /* note: no unit */
}
.card {
border-radius: var(--radius); /* INVALID — no unit */
border-radius: calc(var(--radius) * 1px); /* ok: now it is 8px */
}
Most of the time you should store the unit with the value:
:root { --radius: 8px; }
.card { border-radius: var(--radius); }
Unitless numbers are useful when you want to multiply:
:root { --space-unit: 4; }
.gap-1 { gap: calc(var(--space-unit) * 1px); }
.gap-2 { gap: calc(var(--space-unit) * 2px); }
.gap-3 { gap: calc(var(--space-unit) * 3px); }
Updating from JavaScript
Custom properties are queryable and writable at runtime, which is what really sets them apart from Sass variables.
const root = document.documentElement;
// Read
const accent = getComputedStyle(root).getPropertyValue('--accent').trim();
// Write
root.style.setProperty('--accent', '#10b981');
Setting a property on documentElement.style overrides the stylesheet value because inline styles have higher specificity. Anywhere in the page that reads var(--accent) updates immediately — no class swaps, no rebuilds.
This is the cleanest way to:
- Let users pick an accent colour from a colour picker.
- Drive an animation by mutating a
--scroll-progressvalue from a scroll listener. - Position a tooltip by writing
--xand--yfrom a mouse event.
A small example — a card that follows the mouse:
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
card.style.setProperty('--mx', `${e.clientX - rect.left}px`);
card.style.setProperty('--my', `${e.clientY - rect.top}px`);
});
.card {
background: radial-gradient(
circle at var(--mx, 50%) var(--my, 50%),
rgba(37, 99, 235, 0.2),
transparent 60%
);
}
A 100ms feature.
Try it yourself. Add a theme toggle button to a page. On click, set document.documentElement.style.setProperty('--bg', newColor) and --fg similarly. Confirm every element that references those tokens updates instantly without a refresh.
Inheritance and shadow boundaries
Custom properties inherit by default. A --brand-color declared on :root is visible from every descendant unless something closer in the tree overrides it.
If you specifically want a variable to not inherit, declare it on an element with @property:
@property --hue {
syntax: '<number>';
inherits: false;
initial-value: 220;
}
@property (the Houdini API) also gives you type-checked custom properties, which makes them animatable. Without @property, the browser cannot interpolate a var(--hue) from 0 to 360 over a transition because it does not know it is a number.
For most styling work you do not need @property. Reach for it when you want to animate a custom property or guarantee a type.
A small design system in 30 lines
Putting it all together, here is a tiny but real design system:
:root {
/* Colour tokens */
--color-bg: #ffffff;
--color-fg: #111827;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-accent: #2563eb;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 1rem;
--space-4: 1.5rem;
--space-5: 2rem;
/* Type scale */
--text-sm: 0.875rem;
--text-md: 1rem;
--text-lg: 1.25rem;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
}
:root.dark {
--color-bg: #0b1020;
--color-fg: #e5e7eb;
--color-muted: #9ca3af;
--color-border: #1f2937;
--color-accent: #60a5fa;
}
body { background: var(--color-bg); color: var(--color-fg); }
.card {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
Every component on the site references these tokens. To rebrand, change five values. To rethink spacing, change five values. The CSS underneath does not move.
Common pitfalls
Forgetting the -- prefix. var(brand-color) is silently invalid — var() only accepts custom property names.
Reading a property on an element that does not inherit it. A variable declared on .card is not visible from a sibling .sidebar. Hoist it to a common ancestor.
Treating var() as if it were calc(). var() returns the token verbatim. To do arithmetic, wrap in calc().
Cyclic references. --a: var(--b) and --b: var(--a) produces the initial value (empty). The browser will not warn loudly — track this one yourself.
Performance. Updating a custom property on the root in a requestAnimationFrame loop is fine; updating it in a tight scroll handler at 120Hz on a thousand elements may not be. Profile if in doubt.
Recap
You now know:
- Custom properties are declared with
--name: valueand read withvar(--name, fallback) - They cascade and inherit like regular CSS properties — scope by the selector you declare them on
- A
:rootset of tokens plus a.darkoverride is the modern way to build themes - Custom properties are readable and writable from JavaScript at runtime, unlike Sass variables
- Store units with values unless you need to multiply with
calc() - Use
@propertyfor typed, animatable, non-inheriting custom properties
Next steps
With custom properties in your toolkit, the next stop is the infrastructure that runs your code — starting with how to feed environment configuration into Docker containers safely.
→ Next: Docker: Environment Variables and Secrets
Questions or feedback? Email codeloomdevv@gmail.com.