How do CSS custom properties differ from Sass variables, and what can you do with one that you cannot do with the other?
answer
- compile time versus run time
- one disappears, one ships
- cascade and inheritance apply
- resolved per element, not per file
- preprocessor still owns selectors, conditions
basics
~20 sCSS custom properties are real values in the cascade: they inherit, can be redefined per selector or per element, and the browser resolves them at computed-value time. Sass variables are compile-time text that never reaches the browser.
solid answer
~50 sThey live at different times. A Sass `$radius: 4px` is resolved by the compiler: every use is substituted before the file ships, and the shipped CSS contains only `4px` — nothing is left to change. A CSS custom property `--radius: 4px` ships as an actual declaration, participates in the cascade, inherits to descendants, and is resolved by the browser at computed-value time, separately for every element. That is what lets one rule such as `.card { border-radius: var(--radius); }` produce different results per variant, per media query, per container, or per element, just by redeclaring `--radius` somewhere above it. The preprocessor still wins where the browser cannot substitute: inside a media query condition, inside a selector name, in loops and maps, and in arithmetic without `calc()`. In practice they coexist — Sass for authoring-time structure, custom properties for anything that must vary at runtime.
code
css · 18 lines:root {
--gap: 8px;
}
@media (min-width: 60rem) {
:root {
--gap: 24px;
}
}
.stack {
display: flex;
gap: var(--gap);
}
.stack--tight {
--gap: 2px;
}go deeper
Be able to state the headline difference in one sentence: the Sass variable is replaced by its value before the browser sees the file, while the custom property is still there and can change.
Explain the mechanism, not just the slogan: custom properties cascade, inherit, and resolve per element at computed-value time, which is what makes one rule produce different values under a variant or a media query.
Show where you draw the line in a real codebase — compile-time values for things that never change, custom properties for anything a class, query or element must vary — and mention the cost of resolving very large token sets.
Own the boundary as policy: which layer of the build owns which values, how tokens get emitted into CSS, and how you stop a team from maintaining the same constant in both systems.
## Two different lifetimes The distinction is not syntax, it is *when the value exists*. A preprocessor variable such as Sass's `$radius: 4px` exists only while the compiler runs. Every `border-radius: $radius` becomes `border-radius: 4px` in the output file. Once the stylesheet is written to disk, the variable is gone: there is no name, no declaration, nothing the browser can see or change. A CSS custom property such as `--radius: 4px` is shipped verbatim. It is a declaration in a rule, matched against elements by a selector, resolved by the browser as part of computing styles for each element. It exists in the running page. ```css /* Sass input */ $radius: 4px; .card { border-radius: $radius; } /* Sass output — the variable is gone */ .card { border-radius: 4px; } /* Custom property — shipped as-is */ :root { --radius: 4px; } .card { border-radius: var(--radius); } ``` ## What runtime existence buys you Because a custom property is a real property, it does three things a compile-time variable cannot. **It cascades.** Two declarations of `--radius` compete by origin, specificity and order, exactly like two declarations of `color`. So a variant class can override the value without touching the rule that consumes it. **It inherits.** By default a custom property flows to descendants. Declaring `--radius` on an ancestor changes every consuming descendant beneath it, which is why setting a property on `:root`, on a component root, or on one element through its `style` attribute all work the same way. **It is resolved per element, at computed-value time.** The same `border-radius: var(--radius)` declaration resolves independently for each matched element, using whatever `--radius` computes to *on that element*. Together that means a value can change after the CSS shipped: ```css :root { --gap: 8px; } @media (min-width: 60rem) { :root { --gap: 24px; } } .stack { gap: var(--gap); } ``` With Sass, changing `$gap` under a media query means recompiling and emitting a second copy of every rule that used it. With a custom property you redeclare one line and every consumer follows. ## What the preprocessor still does better Custom properties are values, and only values. They cannot appear anywhere the browser needs a fixed token before substitution happens: - **In a media query condition.** `@media (min-width: var(--bp))` is not supported in browsers today; `@media (min-width: $bp)` is trivial in Sass because the compiler writes the literal number. - **In a selector or a property name.** Sass can interpolate `.icon-#{$name}` into a class name; `var()` cannot touch selectors at all. - **In arithmetic without calc().** `$gap * 2` is compiler maths; the CSS equivalent must be `calc(var(--gap) * 2)`. - **In control flow.** Loops, conditionals, maps and mixins generate *rules*. Custom properties generate nothing — they only hold values. There is also a cost difference worth naming honestly: a preprocessor variable disappears, so it costs nothing at runtime, while a custom property is a real declaration the browser stores per element and resolves during style computation. For typical stylesheets that cost is unremarkable; declaring hundreds of properties on `:root` that are read on deeply nested trees is where people start measuring. ## Scoping differs too Sass scoping is lexical: a `$x` declared inside a block is visible to the rest of that block in the source file. Custom property scoping is *by the DOM tree*: `--x` declared on `.card` is visible to `.card` and its descendants, whatever file the consuming rule was written in, and however the elements were nested at runtime. ```css .panel { --accent: teal; } .panel .link { color: var(--accent); } /* teal, by inheritance */ .footer .link { color: var(--accent); } /* whatever --accent is there */ ``` ## How they combine The mature answer is that this is not an either/or. Preprocessor variables are good at authoring-time structure — generating rules, keeping numeric scales in one place, computing values that will never change in the browser. Custom properties are the right tool for anything that must vary while the page is running: a variant class, a media or container query, a state class on an ancestor, a value set on one element. Many codebases feed one into the other, emitting custom property declarations from compile-time data, and then consume only `var()` in component rules. The interview trap is calling custom properties "CSS variables, basically Sass variables now built in". They are not a syntax convenience for the same job; they are a different mechanism with a different lifetime, which is exactly why the two survive side by side.
- Why can a Sass variable be used directly in a media query condition when a custom property cannot?Because the compiler substitutes `$bp` before the file is parsed by a browser, so the condition contains a literal length. A custom property is resolved much later, at computed-value time on an element, whereas media query conditions are evaluated against the viewport with no element in play. Browsers today therefore reject `var()` inside a media query condition.
- Is there a runtime cost to defining a large number of custom properties on :root?There is a real but usually small one. Each declaration is a property the browser stores and resolves during style computation, and because they inherit, root-level properties are part of every element's computed style. It is rarely the thing to optimise first, but a very large token set consumed on a very large DOM is measurable, and scoping properties to the subtree that needs them helps.
- Can you set a Sass variable from JavaScript the way you can set a custom property?No. Nothing named `$radius` exists after compilation, so there is no target to write to; the only way to change it is to recompile and reship the stylesheet. A custom property is a live declaration, so changing it on an element changes every descendant declaration that consumes it, with no rebuild involved.
saying these in an interview costs you the question
- Says custom properties are just Sass variables with native syntax
- Thinks custom properties get compiled away before shipping
- Claims a Sass variable can be swapped at runtime by a class
- Uses var() in a media query condition and expects it to work
- Writes width: var(--a) * 2 without calc()