CSS custom properties — also called CSS variables — let you store values once and reuse them everywhere. They unlock theming, dynamic styling, and a level of maintainability that pre-CSS-variables stylesheets could only dream of. This article covers the full pattern: how to declare variables, how to use them, how to build a dark mode, and the gotchas that catch even experienced developers.
What custom properties are
A custom property is a name-and-value pair you define in CSS, prefixed with two dashes:
:root {
--brand-color: #ff8a3d;
--text-color: #e9ecf5;
--bg-color: #0a0d18;
}
h1 {
color: var(--brand-color);
}
That is the whole syntax. The --brand-color variable lives on :root (which is <html>), so every element can read it via var(). You can put a custom property on any element, and its descendants inherit it.
Why they exist
Before custom properties, you would write a colour literal in every rule that needed it. Change the brand colour? Find and replace fifty times. With custom properties, you change one variable and the whole site updates.
But they go further than mere convenience. Because custom properties cascade and can be overridden per-element, you can do dynamic theming (dark mode, multiple brand variants, user-customised colours) with no JavaScript at all.
The cascade: the trick most people miss
Custom properties follow the same cascade rules as other CSS properties. A child can override a parent's variable:
:root {
--bg: white;
--fg: black;
}
.card {
background: var(--bg);
color: var(--fg);
}
.card.dark {
--bg: #0a0d18;
--fg: #e9ecf5;
}
When an element has the dark class, its --bg and --fg change. The background and color declarations stay the same — they automatically pick up the new values. This is the foundation of every dark-mode implementation.
Fallbacks and defaults
You can pass a fallback to var() in case the variable is not defined:
h1 {
color: var(--brand-color, #ff8a3d);
}
If --brand-color is not set anywhere, the heading gets the fallback colour. Useful for components that may be dropped into contexts where the variable is not yet defined.
Theming with custom properties
Here is the canonical dark-mode pattern:
:root {
--bg: #ffffff;
--fg: #1a1a1a;
--accent: #ff8a3d;
--muted: #f0f0f3;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0d18;
--fg: #e9ecf5;
--accent: #ff3d8a;
--muted: #1a2236;
}
}
body {
background: var(--bg);
color: var(--fg);
}
a {
color: var(--accent);
}
The site automatically follows the user's OS preference. Add a manual override with a class on the body, and you have a theme toggle in five more lines:
body.theme-light {
--bg: #ffffff;
--fg: #1a1a1a;
}
body.theme-dark {
--bg: #0a0d18;
--fg: #e9ecf5;
}
A small bit of JavaScript can read the user's preference from localStorage and toggle the class on page load. The CSS does the rest.
Custom properties in calc()
You can use custom properties inside calc(), which unlocks all kinds of dynamic computations:
:root {
--base-size: 1rem;
}
.card {
font-size: calc(var(--base-size) * 1.25);
padding: calc(var(--base-size) * 2);
}
Change --base-size on a parent, and all descendants scale proportionally. That is the elegant alternative to writing media queries for every text-size adjustment.
Animating custom properties
You can animate custom properties with @property:
@property --hue {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
.button {
background: hsl(var(--hue), 70%, 50%);
transition: --hue 0.3s;
}
.button:hover {
--hue: 200;
}
The @property declaration tells the browser the type and initial value of the variable, which makes it animatable. Before @property, custom properties were always treated as strings and could not be smoothly transitioned. Now you can animate any numeric or colour property.
JavaScript and custom properties
You can read and write custom properties from JavaScript:
const styles = getComputedStyle(document.documentElement);
const brand = styles.getPropertyValue("--brand-color");
console.log(brand);
document.documentElement.style.setProperty("--brand-color", "#ff3d8a");
Use this for live theme switching, brand customisation, dynamic sizing, or any UI that responds to user input.
Common pitfalls
- Forgetting the
var()wrapper.color: --brand-color;does nothing. Always usevar(--brand-color). - Using a variable in a place that does not accept it. Some CSS properties accept only specific value types. If your variable contains a number and you use it where a colour is expected, it silently fails.
- Animating without
@property. Without the registration, the browser treats the variable as a string and cannot interpolate it smoothly. - Naming collisions. Custom properties are global to the cascade. If you define
--primaryin two unrelated component styles, the latter overrides the former. Use specific names or scope carefully.
Custom properties in media queries
Custom properties work beautifully inside media queries. The whole @media block can override a set of variables, and every element using them updates automatically. The pattern:
:root {
--container-padding: 1rem;
--container-width: 100%;
}
@media (min-width: 768px) {
:root {
--container-padding: 2rem;
--container-width: 720px;
}
}
@media (min-width: 1024px) {
:root {
--container-padding: 3rem;
--container-width: 960px;
}
}
.container {
width: var(--container-width);
padding: var(--container-padding);
}
The container class stays exactly the same across breakpoints — only the variables change. This is dramatically cleaner than writing three separate rules for each breakpoint.
Custom properties as API surface
Think of custom properties as a public API for your component. By reading them in calc() and var(), you create components that consumers can style from the outside without writing CSS that touches the component's internals:
.button {
--button-bg: var(--brand-color, #ff8a3d);
--button-fg: white;
--button-padding: 0.75rem 1.5rem;
background: var(--button-bg);
color: var(--button-fg);
padding: var(--button-padding);
}
Now a consumer can override any of these without touching the button class:
.danger-button {
--button-bg: crimson;
}
This is how well-designed component libraries work. The internal CSS is fixed; the public API is the variables. shadcn/ui and Radix UI follow this pattern closely.
Storing complex values: lists and gradients
Custom properties can store any CSS value, not just simple colours and lengths. Gradients, shadows, transitions, even whole background shorthand declarations:
:root {
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
--card-bg: linear-gradient(135deg, #1a2236, #0a0d18);
}
.card {
background: var(--card-bg);
box-shadow: var(--card-shadow);
}
One gotcha: url(...) in custom properties can have issues with commas being interpreted as argument separators. Always quote the entire URL value or use individual variables for the URL.
Custom properties and accessibility
Custom properties are particularly useful for accessibility. You can wire up prefers-reduced-motion, prefers-color-scheme, and prefers-contrast into a single design system that adapts automatically:
:root {
--animation-duratio<h2>Further reading</h2>
<p>CSS custom properties are now mature and widely supported, but the specification is still evolving. We trust MDN for the reference and the CSS Working Group drafts for the long-term direction of the feature.</p>
<ul>
<li><strong><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties" target="_blank" rel="noopener">MDN CSS custom properties</a target="_blank" rel="noopener noreferrer"></strong> — the MDN guide to declaring and using CSS custom properties, with browser support notes and practical examples.</li>
<li><strong><a href="https://developer.mozilla.org/en-US/docs/Web/CSS/var" target="_blank" rel="noopener">MDN var()</a target="_blank" rel="noopener noreferrer"></strong> — the reference for the var() function, including the fallback value syntax and invalid-value handling.</li>
<li><strong><a href="https://drafts.csswg.org/css-variables/" target="_blank" rel="noopener">CSSWG custom properties spec</a target="_blank" rel="noopener noreferrer"></strong> — the CSS Working Group specification for custom properties, including the cascade and inheritance rules.</li>
</ul>
n: 0.3s;
--bg: white;
--fg: black;
}
@media (prefers-reduced-motion: reduce) {
:root { --animation-duration: 0s; }
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0d18;
--fg: #e9ecf5;
}
}
@media (prefers-contrast: more) {
:root {
--bg: black;
--fg: white;
}
}
One stylesheet, four responsive variants. Custom properties make this manageable in a way that traditional CSS does not.
Design tokens from JSON
Many teams define design tokens in a JSON file and compile them into custom properties:
{
"color": {
"brand": "#ff8a3d",
"accent": "#ff3d8a"
},
"font": {
"body": "system-ui"
}
}
A small build step turns this into a CSS file with custom properties. Style Dictionary and Theo are popular tools for this pipeline. The benefit: designers and developers share a single source of truth, and the same tokens can feed CSS, iOS, Android, and other platforms.
Further reading
CSS custom properties look simple but they unlock a lot. The following references are where we go when we want depth.
- MDN: Using CSS Custom Properties — The tutorial we send to every new frontend hire.
- MDN: var() reference — The most important function for using custom properties in real code.
- CSSWG: CSS Custom Properties spec — When you need to know the exact behaviour of @property, fall back to the spec.
FAQ
What is the difference between custom properties and preprocessor variables?
Preprocessor variables (Sass $var, Less @var) are replaced at build time. They cannot change at runtime. Custom properties are part of the cascade and can be updated dynamically by CSS or JavaScript. Use preprocessor variables for build-time constants (palette definitions, breakpoints) and custom properties for runtime theming.
Can I use custom properties with media queries?
Yes. You can change a custom property inside a media query, and every element using it will update. This is the cleanest way to theme responsive sites.
Are custom properties supported everywhere?
Yes — every browser since 2017 supports them. @property is newer (2021) but also well-supported as of 2026.
How do I share variables across multiple stylesheets?
Define them on :root in one global stylesheet imported first. They cascade down to everything else. Most frameworks have a single theme.css or tokens.css for exactly this purpose.
What is the difference between CSS variables and design tokens?
Design tokens are a higher-level concept — named values that represent design decisions (colour, spacing, typography). Custom properties are the CSS mechanism that implements them. Tokens might live in a JSON file that gets compiled into custom properties, or they might be the custom properties directly. They are the same idea at different layers.
Can I use calc() inside var()?
No. calc() wraps the variable, not the other way around. var(--foo + 10px) does not work; calc(var(--foo) + 10px) does.
What is the order of operations for custom properties?
Custom properties are resolved at the point of use, not at declaration. So a var() in color is resolved when computing color, not when the property is declared. This means animations and computed values can be passed through, which is what makes @property work.
How do I debug a custom property?
Use the browser dev tools. Inspect the element, then look at the computed styles panel. The custom property's resolved value appears there. You can also read it from JavaScript with getComputedStyle(el).getPropertyValue("--foo").
Should I namespace my variables?
Yes. Use a component or section prefix to avoid collisions: --card-bg, --button-bg. The BEM-like prefix makes your CSS more portable and easier to maintain.
Take time to refactor carefully, run tests, and ship with confidence.
Take your time and verify each pattern with real use cases before adopting it broadly.
Custom properties are one of those rare features that improve every aspect of CSS — readability, maintainability, performance, theming, accessibility. If you take one thing from this article, take this: define your design tokens as variables on :root, and your whole stylesheet becomes easier to reason about. Every other pattern flows from there.
Homework
Refactor a small project to use custom properties throughout:
- Identify the colours, font sizes, and spacing values you use repeatedly.
- Define them as custom properties on
:root. - Replace every literal value with
var(). - Add a dark-mode media query that overrides the variables.
- Add a theme toggle button that flips between light and dark via a body class.
- Use
@propertyto animate the transition smoothly.
By the end you will have a themable design system with maybe 50% less CSS than you started with. For more on theming and colour choices, see our Designing for Color Blindness article.