Custom properties
Variables that live in the cascade — not a Sass shortcut, a different tool entirely.
You write the brand orange #c96442 thirty times across your stylesheet. The brand changes to a deeper red. You find-and-replace, you miss two, the homepage goes half-orange and half-red for a release. CSS custom properties solve this — they let you name a value once and reference it anywhere — and they do it in a way that is more powerful than the variables in any preprocessor that came before. The catch is that the extra power only shows up if you understand how they live in the cascade. This lesson is about that.
Defining and using a custom property
A custom property is any property whose name starts with two dashes, like --brand. You set it the same way as any other property:
:root {
--brand: #c96442;
--space-md: 16px;
--font-display: 'Georgia', serif;
}:root is a selector that targets the document's root element — for HTML pages, that is <html>. Defining custom properties on :root makes them available everywhere on the page, because they inherit (yes, custom properties inherit by default — unlike most box properties).
You read a custom property back with var(--name):
.card {
background: var(--brand);
padding: var(--space-md);
font-family: var(--font-display);
}The browser swaps var(--brand) for #c96442 at render time. Change the --brand value in one place — :root — and every rule that references it picks up the new colour automatically. That is the obvious win, and on its own it would already be useful. The interesting part is what happens when you set a property somewhere other than :root.
Scoping by selector
A custom property defined on a selector is available to every element that matches that selector and every descendant. Because custom properties inherit, you can override the value for a part of the tree just by setting it on a parent.
:root { --brand: #c96442; } /* default brand */
.dark-section { --brand: #ffb78a; } /* lighter on dark backgrounds */Every element on the page reads --brand as #c96442, except elements inside .dark-section, which read it as #ffb78a. You did not write a new rule for "background of cards inside .dark-section" — the existing card rule (background: var(--brand)) just resolved differently because the variable's value changed in that part of the tree.
This is the part Sass and Less variables cannot do. A Sass $brand is a compile-time substitution — by the time the CSS reaches the browser, $brand has been replaced with a literal value, and there is no way to say "this part of the page uses a different $brand" without writing all the dependent rules twice. Custom properties, in contrast, are evaluated by the browser at render time, against the cascade, against the current element. Theme switching, dark mode, component variants — all of it gets simpler because you can change one variable instead of duplicating dozens of rules.
:root { --gap: 8px } .grid { --gap: 16px }. A child of .grid reads var(--gap). What value does it get?Fallbacks and graceful degradation
var() accepts a second argument as a fallback. If the named property is not defined (or is invalid), the fallback is used instead.
.tag {
background: var(--accent, #888);
padding: var(--space-tag, 4px 8px);
}If --accent is defined somewhere up the tree, the tag uses it; otherwise, the tag falls back to #888. The fallback is what makes a component reusable across projects — drop it in a stylesheet that has not defined --accent and it still has a sensible look.
The fallback also kicks in when the variable's value is invalid for the property:
:root { --space: not-a-length; }
.box { padding: var(--space, 12px); }--space is defined, but its value is nonsense as a length. The browser does not silently use 0 — it falls back to the second argument. This is one of the few places where CSS gives you a real "if invalid, use this instead" guarantee.
A subtle thing worth knowing: a custom property itself is not type-checked. You can write --foo: anything-you-want and the parser accepts it. The check only happens when var(--foo) is used in a rule — and at that point, if the value cannot be parsed for that property, it falls back.
.btn { color: var(--brand-color, #2d2418) } in a component library. A consumer's project does not define --brand-color. What do they see?Custom properties are not Sass variables
Sass (and Less, and the older preprocessors) gave us variables before CSS had them natively. If you have used Sass, the syntax for custom properties looks similar — but there are three differences worth knowing.
Sass variables are compile-time. Custom properties are runtime. A Sass $brand is replaced with a literal value before the CSS file is even sent to the browser. By the time the browser sees the file, the variable is gone. A custom property is alive in the browser — you can change it from JavaScript (element.style.setProperty('--brand', '#fff')), or with media queries, or with :hover — and every dependent rule recalculates instantly. Theme toggles and live previews are this difference.
Custom properties cascade and inherit; Sass variables don't. Setting $brand inside a Sass mixin defines a local Sass variable that shadows the outer one in that file's source. There is no concept of "this $brand is overridden for everything inside this DOM subtree." With CSS custom properties, that scoping comes for free.
Sass variables can hold anything; custom properties hold a string until used. $breakpoint: 800px in Sass can be used inside a @media query (@media (min-width: $breakpoint)). A CSS custom property cannot — @media (min-width: var(--breakpoint)) is not valid CSS, because media query parsing happens before custom-property substitution. The two languages live at different layers, and custom properties are mostly a value-substitution tool, not an everywhere-substitution tool.
The practical outcome: if your project uses Sass, the right play today is usually to let CSS custom properties handle theming and runtime variation, and let Sass handle compile-time math, mixins, and partials. They are complementary, not competing.
You cannot use a custom property inside a media query condition (@media (min-width: var(--bp)) is invalid). If you need a breakpoint to be reusable, define it once in a Sass variable, a JS constant, or a fragment of CSS you copy — not in :root.
:root { --pad: 16px } .compact { --pad: 8px } .card { padding: var(--pad) }. Two cards, one inside .compact and one not. What happens?