CSS custom properties are real, live, cascade-aware variables — not a build-time text substitution like Sass variables. They inherit, they can be overridden per-scope, they're readable/writable from JavaScript, and they're the mechanism that makes runtime theming and Shadow DOM styling possible.
csscustom-propertiescss-variablestheming
Knowledge Check
4 questions · pass at 70%
0/4
easymcq
1. What is the fundamental difference between a CSS custom property and a Sass variable?
Interview Questions
3 questions
Cheatsheet
Download-ready reference
Cheatsheet
Declare: --name: value; (on :root for global, or any selector to scope it)
Use: property: var(--name, fallback-if-unset);
KEY DIFFERENCE from Sass variables: REAL, LIVE, runtime — not compile-time text substitution
→ inherits down the DOM, cascades, can be overridden per-scope, readable/writable via JS
Overriding per-scope (the core theming mechanism):
:root { --accent: blue; }
[data-theme="dark"] { --accent: purple; } ← everything using var(--accent) updates automatically
JS read/write (live, no re-render needed for the CSS itself):
getComputedStyle(el).getPropertyValue('--x')
el.style.setProperty('--x', 'red')
Arithmetic REQUIRES calc():
width: calc(var(--base) + 10px); ← var() alone does NOT do implicit math
@property — typed custom properties (enables smooth animation):
@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
→ untyped custom properties CANNOT smoothly animate/transition without this
Shadow DOM theming: custom properties are the ONE mechanism that crosses the
shadow boundary intentionally — the standard way to expose a Web Component's theme API
Use for: genuinely themeable/reusable/JS-controlled values
Don't: wrap every literal value reflexively with no real reason to vary
CSS custom properties (informally "CSS variables," --name: value) are fundamentally different from Sass/Less variables, even though the syntax looks similar: Sass variables are resolved at compile time — by the time CSS reaches the browser, they're already replaced with plain literal values, gone entirely. CSS custom properties are resolved at runtime, live in the browser, participate in the cascade and inheritance exactly like any other CSS property, can be changed dynamically via JavaScript or media queries, and can be overridden differently at different points in the DOM tree.
:root (equivalent to html but with higher specificity, and the conventional place for global custom properties) is the typical top-level declaration point — but custom properties can be declared on any selector, scoping them to that element and its descendants.
Real inheritance and cascade — the key differentiator from Sass variables#
.card { --card-bg: white; }.card.dark { --card-bg: #1f2937; } /* overrides only within .dark cards */.card { background: var(--card-bg);}
Because custom properties are real CSS, they inherit down the DOM tree and can be redefined at any scope — a component can define a default, and any ancestor (a theme wrapper, a specific instance) can override just that one variable for everything beneath it, without needing a build step, a different class name per variant, or JavaScript. This is exactly the mechanism that makes runtime theme switching (light/dark mode, brand theming) clean: redefine a handful of custom properties at a high-level scope (e.g. [data-theme="dark"]), and every component using var(--...) throughout the tree picks up the new values automatically, live, with zero JS re-render needed for the CSS itself.
.button { color: var(--button-text-color, white); /* uses white if --button-text-color is unset */}
The second argument to var() is a fallback used if the custom property isn't defined (or is invalid) in the current scope — essential for building components that work correctly even when the consuming application hasn't defined every possible theming variable.
// Readconst styles = getComputedStyle(document.documentElement);const primary = styles.getPropertyValue("--primary-color");// Write — updates LIVE, cascades to every element using var(--primary-color)document.documentElement.style.setProperty("--primary-color", "#dc2626");
This is what powers JS-driven runtime theming (a color picker that live-updates a whole design system) without touching a stylesheet or triggering a re-render of anything beyond what the browser needs to repaint.
/* Inside a Shadow DOM component */.card { border-color: var(--card-border-color, #ccc);}
/* From the outside page, setting a custom property that crosses INTO shadow DOM */my-card { --card-border-color: red; }
Covered in the Web Components topic — custom properties are the one styling mechanism that intentionally crosses the Shadow DOM encapsulation boundary, making them the standard way to expose theming hooks from an otherwise style-isolated component.
Registering a custom property via @property gives it an actual type (not just an arbitrary string), which — critically — enables it to be smoothly animated/transitioned, something untyped custom properties cannot do (the browser can't interpolate between two arbitrary token strings, only between two typed values it understands, like an angle or a length). This is a genuinely newer capability, worth checking current browser support for the specific use case before relying on it as the only implementation.
1. Treating custom properties as if they were Sass variables (compile-time constants)#
/* Doesn't work the way Sass variable math might suggest, without calc() */.item { width: var(--base-width) + 10px; } /* invalid — not a calc expression *//* Right */.item { width: calc(var(--base-width) + 10px); }
Any arithmetic on a custom property's value must go through calc() — custom properties substitute their literal value as CSS token text, they don't participate in implicit arithmetic the way a preprocessor variable might in some contexts.
2. Not providing a fallback for component-level custom properties#
/* If --my-component-color is never defined by the consumer, this is just invalid/empty */.thing { color: var(--my-component-color); }
For any custom property a reusable component relies on but doesn't itself guarantee is set, always provide a sensible fallback as the second var() argument, so the component degrades gracefully rather than rendering with an effectively-unset property.
3. Expecting an untyped custom property to transition/animate smoothly#
/* Doesn't animate smoothly by default — untyped custom properties can't be interpolated */.el { transition: --my-color 0.3s; }
Without registering the property's type via @property, the browser treats its value as an opaque token string it can't interpolate between — animation appears to "jump" rather than transition smoothly. @property with an explicit syntax fixes this for supporting browsers.
4. Defining every single value as a custom property, including ones that never vary#
Custom properties add real value for anything that's genuinely themeable, reusable, or JS-controlled — reflexively wrapping every literal value in the codebase as a --variable even when it's never overridden anywhere adds indirection with no actual benefit. Reserve them for values with a genuine reason to vary by scope, theme, or runtime state.
5. Confusing custom property scoping with JavaScript variable scoping intuitions#
A custom property redefined inside a media query or a specific class doesn't "leak" globally, but it also isn't lexically scoped the way a JS let is — it follows CSS's normal cascade/specificity/inheritance rules, which can surprise engineers used to reasoning about scope in JS terms.
Use custom properties for anything that's genuinely themeable (colors, spacing scale, border-radius scale) or needs runtime/JS control — not as a blanket replacement for every literal value.
Define global design tokens at :root, and allow component/scope-level overrides where meaningful.
Always provide sensible fallback values (var(--x, fallback)) for custom properties a reusable component depends on but doesn't itself guarantee.
Wrap arithmetic in calc() — custom properties don't do implicit math.
Use @property when a custom property genuinely needs to animate/transition smoothly, checking browser support for your audience first.
Use custom properties as the theming API for Shadow DOM/Web Components, since they're the one mechanism designed to cross that encapsulation boundary intentionally.