Module 8: Advanced CSS & Architecture
CSS Variables (Custom Properties)
How custom properties are declared, inherited and resolved at runtime, how they differ from Sass variables, and the patterns that make theming and component APIs possible.
A custom property is a property you name yourself, always starting with two dashes. You store a value in it, and read it back with var().
:root {
--brand: #0b5fff;
--radius: 8px;
--space: 1rem;
}
.btn {
background: var(--brand);
border-radius: var(--radius);
padding: calc(var(--space) / 2) var(--space);
}
The names are case-sensitive: --Brand and --brand are different properties.
They are real properties, so they cascade and inherit#
This is what separates CSS variables from variables in preprocessors. A custom property takes part in the cascade like any other property, and it inherits by default. The value a child sees is whatever its nearest ancestor declared.
:root { --accent: #0b5fff; }
.danger-zone { --accent: #b42318; } /* redefined for this subtree only */
.btn { background: var(--accent); }
<button class="btn">Save</button> <!-- blue -->
<section class="danger-zone">
<button class="btn">Delete</button> <!-- red, same CSS rule -->
</section>
:root --accent: blue
├── button.btn → var(--accent) = blue
└── section.danger-zone --accent: red
└── button.btn → var(--accent) = red (inherits from the closest ancestor that sets it)
One rule produced two colors because the variable's value depends on where the element sits in the tree. A Sass variable cannot do that. It is replaced at compile time and does not exist in the browser.
They are live#
The browser resolves var() while computing styles, which means changes at runtime take effect immediately, including from media queries and JavaScript.
:root { --gutter: 1rem; }
@media (min-width: 768px) { :root { --gutter: 2rem; } }
.layout { padding-inline: var(--gutter); }
document.documentElement.style.setProperty("--brand", "#7c3aed");
const brand = getComputedStyle(document.documentElement).getPropertyValue("--brand");
One line in JavaScript recolors an entire app. This is the basis of user-selectable themes.
Fallback values#
var() takes an optional second argument used when the variable is not defined:
.btn { color: var(--btn-color, white); }
.card { padding: var(--card-padding, var(--space, 1rem)); } /* fallbacks can nest */
The fallback is used only if the variable is not set. It does not catch invalid values.
"Invalid at computed-value time"#
Here is a subtle behavior. The browser does not check whether a custom property's value makes sense for the property it will end up in until it substitutes it.
:root { --size: banana; }
.box { width: var(--size); } /* width: banana is invalid */
The width declaration becomes "invalid at computed-value time", and the property is treated as unset, which for width means auto. The browser does not fall back to an earlier width declaration you wrote above it. If a layout inexplicably loses a property when a variable is involved, check the computed value of the variable.
Where variables cannot go#
- Inside media query conditions.
@media (min-width: var(--bp))does not work. Queries are evaluated before custom properties exist. - Inside selectors, property names, or URLs of
url()in some contexts. A variable can hold a value, not a piece of syntax.
Useful tricks#
Variables that hold numbers#
A unitless number lets you use it in several ways:
:root { --cols: 3; }
.grid { grid-template-columns: repeat(var(--cols), 1fr); }
.grid-wide { --cols: 5; }
.stagger > * { animation-delay: calc(var(--i) * 100ms); }
calc(var(--n) * 1px) attaches a unit. You cannot do var(--n)px, because that concatenates tokens and is invalid.
Component-level API#
Expose a few variables as the component's public knobs, with defaults:
.btn {
--btn-bg: var(--brand);
--btn-fg: white;
--btn-pad: 0.6em 1.2em;
background: var(--btn-bg);
color: var(--btn-fg);
padding: var(--btn-pad);
}
.btn.ghost {
--btn-bg: transparent;
--btn-fg: var(--brand);
}
Variants become short lists of variables instead of repeated declarations. Someone using your component can override --btn-bg from the outside without a specificity fight.
Theming#
:root {
--bg: #ffffff;
--text: #111827;
--surface: #f3f4f6;
}
[data-theme="dark"] {
--bg: #0b1220;
--text: #e5e7eb;
--surface: #172033;
}
body { background: var(--bg); color: var(--text); }
.card { background: var(--surface); }
Components reference semantic names (--surface), not literal colors, and a theme is a different set of values.
Two tiers: tokens and semantics#
:root {
/* raw palette */
--blue-500: #0b5fff;
--gray-900: #111827;
/* meaning */
--color-action: var(--blue-500);
--color-text: var(--gray-900);
}
Components use the semantic tier. A rebrand then changes one line.
Typed custom properties with @property#
By default the browser treats a custom property as an arbitrary string, so it cannot animate it. @property registers a type:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.spinner {
background: conic-gradient(from var(--angle), #0b5fff, transparent);
animation: spin 2s linear infinite;
}
@keyframes spin { to { --angle: 360deg; } }
Now the browser knows --angle is an angle and can interpolate it, so a gradient angle can be animated. It also lets you set inherits: false and an initial value, which makes variables behave more like real typed properties. All major browsers support it today.
Common mistakes#
- Declaring on
:rootonly and missing the power of scoped overrides. - Expecting a fallback to catch invalid values.
- Naming variables by value (
--blue) instead of purpose (--color-action), so a dark theme has a "blue" that is orange. - Putting variables in media query conditions.
- Too many variables. Variables for every single value create indirection without meaning. Make a variable when a value has a name or is reused.
- Typing
var(--n)px. - Forgetting the double dash.
Practice#
- Build a light and dark theme that switches via a
data-themeattribute onhtml. Add a button that toggles it with JavaScript. - Make a
.btnwith variables as its public API. Create primary, secondary and ghost variants by only changing variables. - Create the "invalid at computed-value time" situation on purpose and see what DevTools shows for the computed value.
- Register an
@propertyfor a gradient angle and animate it.