CSS Custom Properties (Variables)
Native CSS variables — scoping, fallbacks, and using them to build a real, maintainable design system without a preprocessor.
--variable-name and var() — CSS's Native Variables
A CSS custom property is declared with a name that starts with two dashes, and is read back with the var() function. Unlike almost every other CSS feature, custom properties are not a fixed, predefined set of keywords — you invent the name yourself, exactly like naming a variable in any programming language.
:root {
--brand-color: #4285f4;
}
.button {
background: var(--brand-color);
border: 2px solid var(--brand-color);
}:root is a selector that matches the document's root element — <html> — and is the conventional place to declare custom properties meant to be available everywhere on the page (covered fully in Part 02). Once declared, any descendant element can read the value back with var(--brand-color), and the property behaves like any other CSS value wherever it is used.
--brand-color and brand-color (no dashes) are completely different things; only the dashed form can be declared and read with var().A custom property can hold almost any valid CSS value
Custom properties are not limited to colors. A custom property can hold a length, a font stack, a full shorthand value, a gradient, even a comma-separated list — anything that is valid CSS syntax can live inside one.
:root {
--spacing-md: 16px;
--font-stack: 'Inter', system-ui, sans-serif;
--card-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
--transition-fast: 150ms ease-out;
}
.card {
padding: var(--spacing-md);
font-family: var(--font-stack);
box-shadow: var(--card-shadow);
transition: transform var(--transition-fast);
}One meaningful restriction is worth knowing early: a custom property cannot be used as a property name, a selector, or an at-rule keyword — only as a value. You cannot write var(--property-name): red; to dynamically choose which property gets set; custom properties substitute values, not CSS syntax structure itself.
:root for Global Tokens, Any Selector for Local Overrides
Custom properties follow the normal cascade and inheritance rules of CSS — a property declared on an element is available to that element and every one of its descendants, exactly like color or font-family. This is the mechanism that makes them genuinely more powerful than a simple find-and-replace variable system: the same var(--accent-color) reference can resolve to a different actual value depending on where in the DOM it is read.
:root {
--accent-color: #4285f4; /* the site-wide default */
}
.promo-banner {
--accent-color: #facc15; /* only inside .promo-banner, this wins instead */
}
.button {
background: var(--accent-color);
}
/* A .button anywhere on the page renders blue.
A .button nested inside .promo-banner renders yellow — same rule, same
var() reference, different resolved value, purely from DOM position. */This is the core idea behind component-scoped theming: define a small set of custom properties at the top of a component's own selector, let every rule inside that component reference them, and any parent context can locally override just those specific properties without touching the component's CSS at all.
.card {
--card-bg: #ffffff;
--card-border: #e2e8f0;
--card-text: #1a1a1a;
background: var(--card-bg);
border: 1px solid var(--card-border);
color: var(--card-text);
border-radius: 8px;
padding: 20px;
}
/* A dark variant needs to override exactly three values — nothing else */
.card--dark {
--card-bg: #1e293b;
--card-border: #334155;
--card-text: #f1f5f9;
}Custom properties inherit — but they do not "leak" upward
A property declared inside .card is visible to every descendant of .card, but is completely invisible outside it — a sibling element, or the page background, has no access to --card-bg at all unless it is itself nested inside a .card. This is exactly how normal CSS inheritance already works for properties like color; custom properties simply extend that same, familiar mental model to author-defined values.
var()'s Second Argument — What Happens If the Property Isn't Set
var() accepts an optional second argument: a fallback value to use if the custom property it references has not been declared anywhere in scope. This makes components resilient to being dropped into a page that never defined the expected variables at all.
.badge {
background: var(--badge-color, #6b7280);
/* If --badge-color was never declared anywhere in scope,
the badge falls back to a sensible default gray instead of
rendering with no background color at all. */
}The fallback only activates when the referenced custom property is entirely undeclared or is invalid for the property it's used on — it is not the same thing as checking for an empty string or a falsy value the way a fallback works in JavaScript. A custom property that has been explicitly set, even to something unusual, is considered declared and will not trigger the fallback.
.tooltip {
background: var(--tooltip-bg, var(--surface-color, #333));
/* Tries --tooltip-bg first.
If that's undeclared, tries --surface-color.
If THAT is also undeclared, finally falls back to #333. */
}--spacing is declared as the literal string "not-a-length" and used in padding: var(--spacing, 16px);, the fallback is not used, because --spacing was technically declared. Instead, padding becomes invalid for that element and falls back to its own CSS-wide initial or inherited value — a subtly different failure mode than a missing variable, and a real source of confusing bugs when a value comes from a CMS or user input.Fallbacks are especially useful for library and design-system components
A reusable component shipped to multiple teams or projects cannot assume every consumer has set up the same design tokens. Giving every custom property reference a sensible fallback means the component renders reasonably out of the box, and a consuming team only needs to declare the specific tokens they actually want to override.
Building a Real, Small Design System With Custom Properties
The genuine payoff of custom properties shows up once you build a real, consistent set of design tokens — a color palette and a spacing scale — and use them everywhere, rather than repeating raw hex codes and pixel values across dozens of unrelated rules.
:root {
/* Color palette */
--color-primary: #4285f4;
--color-primary-dark: #2f5fc4;
--color-surface: #ffffff;
--color-surface-alt: #f8fafc;
--color-border: #e2e8f0;
--color-text: #1a1a1a;
--color-text-muted: #64748b;
--color-danger: #dc2626;
/* Spacing scale — a consistent multiple of a single base unit */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
/* Radius and shadow tokens */
--radius-md: 8px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
}
.card__title {
color: var(--color-text);
margin-bottom: var(--space-sm);
}
.card__meta {
color: var(--color-text-muted);
font-size: 13px;
}
.button--primary {
background: var(--color-primary);
color: #fff;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
}
.button--primary:hover {
background: var(--color-primary-dark);
}
.alert--danger {
background: var(--color-surface-alt);
border: 1px solid var(--color-danger);
color: var(--color-danger);
padding: var(--space-md);
border-radius: var(--radius-md);
}Every visual decision — what "primary blue" is, how much padding a "medium" gap means, what radius counts as "rounded" — lives in exactly one place. Changing --color-primary from blue to a rebrand green updates every button, link, and highlighted element across the entire site simultaneously, with a single line changed.
:root {
--color-primary: #16a34a; /* was #4285f4 — every consumer updates instantly */
}Live in DevTools, Live in JavaScript, Live Across Media Queries
The single most important thing that separates a CSS custom property from a preprocessor variable (Sass, covered fully in Part 07) is that a custom property is a genuine, live part of the rendered page — resolved by the browser at runtime, not compiled away before the browser ever sees the file. This has consequences that are worth seeing concretely.
1. It is inspectable and editable directly in browser DevTools
Opening any element in the browser's DevTools "Computed" or "Styles" panel shows every custom property currently in scope for that element, with its actually resolved value — and most browsers let you edit that value live and watch the page update instantly, with no rebuild step. This alone makes debugging a theming issue dramatically faster than tracing a preprocessor variable back through a compiled stylesheet.
2. JavaScript can read and write it directly
// Read the current value of a custom property
const styles = getComputedStyle(document.documentElement)
const primary = styles.getPropertyValue('--color-primary').trim()
// Set it — every element referencing var(--color-primary) updates immediately
document.documentElement.style.setProperty('--color-primary', '#16a34a')This is genuinely impossible with a Sass variable, because Sass variables do not exist anymore once compilation has finished — there is nothing left in the shipped CSS file for JavaScript to find or change. A live custom property is the mechanism behind features like a user-controlled accent color picker, or an interactive theme switcher that updates instantly with no page reload.
3. Its value can change per media query, without duplicating every rule
:root {
--content-padding: 16px;
}
@media (min-width: 768px) {
:root {
--content-padding: 32px;
}
}
.page-content {
padding: var(--content-padding);
}
/* No media query needed inside .page-content itself — it just reads
whatever --content-padding currently resolves to. */This is a genuinely different pattern from writing a separate media query per component — the responsive logic lives once, at the token definition, and every rule that consumes the token automatically inherits the responsive behavior without repeating a single media query.
prefers-color-scheme media query (or a manually toggled class) redefines a small set of color tokens at :root, and every component that already consumes those tokens re-themes itself automatically — with zero component-level code aware that dark mode exists at all.Putting Runtime Scoping and Redefinition Together
Combining scoping (Part 02) with the runtime redefinition idea from Part 05 produces the standard, production-grade pattern for a light/dark theme toggle — no JavaScript theming library, no duplicated component CSS, just custom properties redefined at two different scopes.
:root {
--bg: #ffffff;
--surface: #f8fafc;
--text: #1a1a1a;
--border: #e2e8f0;
}
[data-theme="dark"] {
--bg: #0f172a;
--surface: #1e293b;
--text: #f1f5f9;
--border: #334155;
}
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--surface);
border: 1px solid var(--border);
}const toggleButton = document.querySelector('#theme-toggle')
toggleButton.addEventListener('click', () => {
const root = document.documentElement
const isDark = root.getAttribute('data-theme') === 'dark'
root.setAttribute('data-theme', isDark ? 'light' : 'dark')
})Every component on the page — .card, and anything else written against var(--surface), var(--text), and var(--border) — re-themes instantly the moment the data-theme attribute changes, because the browser re-resolves every var() reference against the new values live. No component file needed to know dark mode was even a feature.
Custom Properties vs Sass Variables — Two Genuinely Different Tools
This site covers Sass properly, in full, later in this phase — this section exists only to draw the one distinction that matters most and that engineers coming from a Sass background frequently get wrong: a Sass variable and a CSS custom property solve superficially similar problems in fundamentally different ways.
/* Sass variable */
$primary-color: #4285f4;
.button { background: $primary-color; }
/* CSS custom property */
:root { --primary-color: #4285f4; }
.button { background: var(--primary-color); }At a glance these look interchangeable. They are not, and the difference is exactly the runtime vs compile-time distinction from Part 05.
Sass variables are resolved once, at build time, and then disappear
A Sass compiler reads $primary-color, substitutes its literal value everywhere it is referenced, and produces plain CSS with no trace of the variable left in it at all. By the time a browser ever sees the file, $primary-color has been replaced by #4285f4 as a hardcoded string, indistinguishable from a value that was always hardcoded.
/* The compiled output — the variable is completely gone */
.button {
background: #4285f4;
}This means a Sass variable cannot be inspected in DevTools (there is nothing left to inspect — just a plain color value), cannot be changed by JavaScript at runtime, and cannot resolve to a different value depending on where in the DOM it happens to be used — because by the time the page is running, the "variable" was never a real, live concept to begin with.
A side-by-side comparison
Sass variable CSS custom property
Resolved At compile time At runtime, in the browser
Visible in DevTools No — already gone Yes — inspectable and editable
Changeable via JS No Yes — setProperty() / getPropertyValue()
Scoped to DOM position No — purely lexical Yes — follows the cascade and inheritance
Works with :root theming No native mechanism Yes — this IS the mechanism
Needs a build step Yes NoA White-Label Rebrand at a Seattle SaaS Company
A Seattle-based project management SaaS product sells a white-label tier: enterprise customers can apply their own brand colors to the dashboard their employees use. The existing CSS, written years earlier with Sass variables compiled once at build time, hardcodes the company's own brand blue directly into the compiled output shipped to every customer.
// _variables.scss
$brand-primary: #4285f4;
// button.scss
.button--primary {
background: $brand-primary;
}
// Compiled output ships with #4285f4 hardcoded — no runtime hook exists
// to change it per customer without recompiling and redeploying the entire
// CSS bundle for every single white-label customer.Why the Sass-only approach cannot support this feature
Supporting per-customer branding with Sass variables alone would require either maintaining a separate compiled stylesheet per customer (an operational nightmare that does not scale past a handful of accounts) or recompiling and redeploying CSS every time a customer updates their brand color in a settings page — completely impractical for a self-serve setting a customer expects to see reflected instantly.
The fix — custom properties, set once per session from the customer's saved settings
/* CSS ships with a sensible default, using the token everywhere */
:root {
--brand-primary: #4285f4; /* fallback until overridden */
}
.button--primary {
background: var(--brand-primary);
}
.nav__link--active {
border-bottom-color: var(--brand-primary);
}
.badge--primary {
background: var(--brand-primary);
}async function applyCustomerBranding() {
const settings = await fetchWorkspaceSettings()
if (settings.brandPrimaryColor) {
document.documentElement.style.setProperty(
'--brand-primary',
settings.brandPrimaryColor
)
}
}
applyCustomerBranding()
// Every component referencing var(--brand-primary) re-themes instantly —
// no CSS recompile, no per-customer stylesheet, no redeploy.The same compiled CSS bundle ships to every customer unchanged; only a single custom property differs, set once at page load from a value stored in the customer's workspace settings. The engineering team keeps Sass for everything it was already good at — nesting, mixins, and build-time convenience — and layers custom properties in specifically for the one requirement Sass genuinely could not satisfy: a value that needs to change per customer, at runtime, without a rebuild.
Four Misconceptions About Custom Properties
5 Interview Questions — With Complete Answers
Custom Property Mistakes Engineers Make Constantly
Errors and Rendering Bugs Custom Properties Produce — And Exactly Why
🎯 Key Takeaways
- ✓A custom property is declared with a double-dash prefix (--name: value;) and read back with var(--name) — the name is author-chosen, not a fixed CSS keyword.
- ✓Custom properties follow the normal CSS cascade and inheritance — declare shared tokens at :root for global scope, and on a specific selector for component-level theming that a parent context can override.
- ✓var() accepts an optional fallback (var(--x, fallback)) that activates only when --x is entirely undeclared in scope — not for empty, zero, or otherwise "falsy" values, and not for a declared-but-invalid value.
- ✓A real design system is built by defining a color palette and spacing scale once as tokens, then having every component consume the tokens instead of repeating raw values.
- ✓Custom properties are resolved at runtime in the browser — inspectable and editable in DevTools, readable/writable via JavaScript (getPropertyValue()/setProperty()), and capable of changing under a media query without duplicating rules.
- ✓The light/dark theme pattern redefines a small set of semantic color tokens inside a [data-theme="dark"] scope (or a prefers-color-scheme query) — every consuming component re-themes automatically with zero component-level changes.
- ✓Sass variables are resolved once at compile time and leave no trace in the shipped CSS — they cannot be inspected, changed by JavaScript, or scoped by DOM position, which is the core distinction from custom properties.
- ✓Real codebases often use both: Sass for build-time authoring convenience, custom properties specifically for anything that needs to be live, themeable, or JavaScript-accessible at runtime.
What comes next
With variables in place, the next module covers CSS transitions — how to make state changes feel smooth, which properties animate cheaply versus expensively, and the timing functions that control how motion feels.
Next → CSS TransitionsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.