Picture a site where your brand blue shows up in forty different places — buttons, links, borders, focus rings, the header. One day the brand changes that blue slightly. Without variables, you’re hunting through your stylesheet replacing the same hex code over and over, praying you didn’t miss one. With CSS variables, you change it in a single spot and the whole site follows.
CSS variables — officially called custom properties — let you give a value a name, store it once, and reuse it anywhere. They’re built right into the language (no build step, no preprocessor needed), they work live in the browser, and they unlock things plain CSS never could, like theme switching and values that respond to the page around them. Let’s get comfortable with them.
Declaring a variable
A custom property is just a property whose name starts with two dashes (--). You declare it like any other CSS declaration, inside a rule:
:root {
--brand-color: #00b8e6;
--spacing: 16px;
--max-width: 1200px;
}
That :root selector matches the very top of the document (the <html> element), so anything you declare there is available across the entire page. The names are yours to invent — --brand-color, --spacing, whatever reads clearly. The double-dash prefix is what tells CSS “this is a custom property, not a typo.”
Custom properties are case-sensitive
Unlike normal CSS property names, custom property names are case-sensitive. --brand-color and --Brand-Color are two different variables. Pick a convention — lowercase with dashes is the common one — and stick to it everywhere so you never trip over a stray capital.
Reading a variable with var()
Declaring a variable does nothing on its own. To actually use the stored value, you read it with the var() function:
.button {
background-color: var(--brand-color);
padding: var(--spacing);
}
.container {
max-width: var(--max-width);
}
Wherever var(--brand-color) appears, CSS substitutes in #00b8e6. Now that color lives in exactly one place. Change --brand-color at the top and every rule that reads it updates at once — the button, and anything else pointing at the same variable. That single source of truth is the whole reason variables earn their keep.
Fallback values
Sometimes you read a variable that might not be set. var() accepts a second argument — a fallback used when the variable is missing or invalid:
.card {
/* if --card-bg isn't defined, use white */
background-color: var(--card-bg, white);
padding: var(--card-padding, 20px);
}
If --card-bg exists, its value wins. If it doesn’t, you get white instead of a broken declaration. Fallbacks make your CSS more robust, especially for reusable components that might be dropped into pages where certain variables haven’t been defined.
Scope: where a variable lives
Here’s where variables get genuinely powerful. A custom property isn’t necessarily global — it lives on the element you declared it on, and it’s inherited by that element’s descendants. Declare it on :root and it’s effectively global. Declare it on a smaller element and it only applies inside that element’s subtree:
:root {
--text-color: #333; /* global default */
}
.alert {
--text-color: #b00020; /* overridden, but only inside .alert */
color: var(--text-color);
}
p {
color: var(--text-color); /* uses #333, unless it's inside .alert */
}
A <p> out in the open reads the global #333. A <p> inside .alert reads #b00020, because the closer declaration wins for that subtree. This scoping follows the normal inheritance rules — the nearest definition up the tree is the one that applies. It lets a component carry its own little set of values without leaking them to the rest of the page.
Think in inheritance, not in JavaScript variables
A CSS variable isn’t a programming variable that you set once globally. It’s a value that flows down the element tree like color or font-size do. The same var(--x) can resolve to different values on different elements, depending on what was declared above each one. Once that clicks, scoping stops feeling mysterious.
The killer feature: theming
Put scoping together with one extra trick and you get theme switching almost for free. Define your colors as variables on :root, then override them when a certain class or attribute is present:
:root {
--bg: #ffffff;
--text: #1a1a1a;
--accent: #00b8e6;
}
[data-theme="dark"] {
--bg: #0d1117;
--text: #e6edf3;
--accent: #4fd6f5;
}
body {
background-color: var(--bg);
color: var(--text);
}
Every rule reads the variables, never the raw colors. To flip the whole site to dark mode, you don’t touch a single component rule — you just add data-theme="dark" to the <html> or <body> element (usually with one line of JavaScript), and every variable swaps to its dark value at once. This is exactly how modern dark-mode toggles work under the hood, and it’s almost impossible to do cleanly without custom properties.
Variables and JavaScript
Because custom properties are live in the browser, JavaScript can read and write them at runtime — something a preprocessor variable could never do. You set a variable from script with setProperty:
// read a variable
const styles = getComputedStyle(document.documentElement);
const accent = styles.getPropertyValue('--accent');
// change a variable — every rule using it updates instantly
document.documentElement.style.setProperty('--accent', '#ff6b6b');
Change one variable from JavaScript and the page repaints with the new value everywhere it’s used — no need to touch individual elements. This is how you build live theme pickers, user-adjustable accent colors, or sliders that resize spacing across a whole layout, all driven by a single property. If you want a refresher on grabbing elements from the page first, the JavaScript and the DOM guide covers document.documentElement and friends.
A practical example
Let’s tie the pieces together into a small component that’s fully driven by variables — easy to theme, easy to tweak:
:root {
--card-radius: 12px;
--card-pad: 20px;
--card-bg: #ffffff;
--card-border: #e2e8f0;
--brand: #00b8e6;
}
.card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--card-radius);
padding: var(--card-pad);
}
.card__button {
background-color: var(--brand);
border-radius: calc(var(--card-radius) / 2);
padding: calc(var(--card-pad) / 2);
}
Notice how the button reuses the card’s variables — and even does math on them with calc(), so its radius and padding stay proportional to the card’s. Adjust --card-pad once and both the card and its button rescale together. That kind of relationship between values is something you simply can’t express with hardcoded numbers. If you’d like to go deeper on calc() and friends, see the CSS calc(), clamp() and min/max guide.
Variables don't work inside media queries' conditions
You can use var() for property values, but not inside the condition of a media query — @media (min-width: var(--bp)) does not work. The breakpoint number in a media query has to be a literal value. Variables shine for the values you apply; the query test itself still needs a real number.
A few good habits
Custom properties are simple, but a little discipline keeps them tidy:
- Define globals on
:root. Keep your palette, spacing scale, and key sizes in one place at the top so they’re easy to find and change. - Name by meaning, not by appearance.
--brandages better than--blue. When the brand color changes to green, a variable named--blueholding green is a small daily insult. - Lean on fallbacks for reusable components.
var(--x, sensible-default)means a component still renders even when dropped into a page that forgot to define--x. - Use scoping on purpose. Override variables on a component’s root element to theme just that component, without touching anything else.
If you’re building out a palette and want a head start on the declarations, a CSS variables generator can scaffold a :root block of custom properties for you to drop in and edit.
Wrapping up
CSS variables turn scattered, repeated values into one source of truth — and then go further than any preprocessor by living and changing right in the browser:
- Declare a custom property with a
--name(case-sensitive), usually on:rootfor global reach. - Read it with
var(--name), and add a fallback:var(--name, default). - Variables are inherited and scoped — override one on a smaller element to affect only that subtree.
- Override variables on a class or
data-attribute to switch entire themes (light/dark) in one move. - JavaScript can read and set them live with
getPropertyValueandsetProperty, so the page updates instantly.
Get comfortable here and your CSS becomes dramatically easier to maintain and re-theme. Next we’ll look at the functions that make values dynamic — calc(), clamp(), and min()/max() — which pair beautifully with the variables you just learned.