Custom Properties and Modern CSS
Newer CSS that makes stylesheets easier to maintain: custom properties and design tokens, calc(), nesting, the :has() and :is() selectors, and how to check browser support with Baseline.
Custom properties
A custom property (often
called a CSS variable) is a property whose name starts with two
hyphens and that you define yourself. It does nothing on its own; it
stores a value that other declarations can use. Custom properties
are usually declared on :root (the
<html> element) so the whole page can use them.
They have worked in all major browsers since 2017.
<p class="notice">Your meter reading has been saved.</p>
<a class="button" href="#">View your bill</a>
:root {
--kestrel-green: #1f6f43;
--space: 0.75rem;
}
.notice {
border-left: 4px solid var(--kestrel-green);
padding: var(--space);
}
.button {
display: inline-block;
padding: var(--space) calc(var(--space) * 2);
background: var(--kestrel-green);
color: white;
}
Change --kestrel-green in one place and both the border
and the button follow. Names are case-sensitive:
--Space and --space are two different
properties.
var() and fallback values
The var() function inserts
the value of a custom property into another declaration. A second
argument is a fallback, used when the custom property is not defined
at all: color: var(--link-color, #1f4e79). Everything
after the first comma is the fallback, so it may contain commas
itself (var(--font, Georgia, serif)), and it can be
another var().
Scope, inheritance and invalid values
Custom properties follow the normal rules of CSS. They are set by selectors, so they can have a different value on different elements, and they are inherited: a value set on an element is visible to all its descendants unless one of them sets its own. This lets a component offer a "setting" that variants change:
<span class="badge">Planned</span>
<span class="badge badge--outage">Outage</span>
<span class="badge badge--ok">Restored</span>
.badge {
--badge-color: #555; /* default */
display: inline-block;
padding: 0.25rem 0.5rem;
border: 2px solid var(--badge-color);
color: var(--badge-color);
font-weight: bold;
}
.badge--outage { --badge-color: #a4161a; }
.badge--ok { --badge-color: #1f6f43; }
The variants change one value; the rules that use it stay in one
place. Because the value is looked up in the browser at the moment
it is used, it can also change with a media query, a class added by
JavaScript, or a style attribute. That is the big
difference with variables in preprocessors such as Sass, which are
replaced by fixed values when the stylesheet is compiled:
:root {
--gap: 0.5rem;
}
@media (width >= 40rem) {
:root {
--gap: 1.5rem; /* every var(--gap) on the page follows */
}
}
Design tokens
A design token is a named design decision, such as a color, a spacing step, a font size or a corner radius, stored in one place and used everywhere instead of the raw value. Custom properties are the natural way to implement tokens in CSS. Design systems usually have two levels:
-
Primitive tokens name the raw palette:
--blue-700: #1f4e79. -
Semantic tokens say what a value is for:
--color-action: var(--blue-700). Components only use semantic tokens.
The second level is what makes theming cheap: a dark theme redefines a handful of semantic tokens and every component follows. Both panels below use exactly the same component CSS; the second panel only redefines three tokens:
<section class="panel">
<h2>Meter reading</h2>
<p>Submit your reading before 1 December.</p>
<a class="action" href="#">Submit reading</a>
</section>
<section class="panel theme-dark">
<h2>Meter reading</h2>
<p>Submit your reading before 1 December.</p>
<a class="action" href="#">Submit reading</a>
</section>
:root {
/* Primitive tokens: the palette */
--blue-700: #1f4e79;
--blue-200: #a9c8e8;
--gray-900: #1a1a1a;
--white: #ffffff;
/* Semantic tokens: what each value is for */
--color-bg: var(--white);
--color-text: var(--gray-900);
--color-action: var(--blue-700);
--space: 1rem;
}
.theme-dark {
--color-bg: var(--gray-900);
--color-text: var(--white);
--color-action: var(--blue-200);
}
/* Components use semantic tokens only */
.panel {
margin-bottom: var(--space);
padding: var(--space);
border: 1px solid var(--color-text);
background: var(--color-bg);
color: var(--color-text);
}
.panel h2 {
margin: 0 0 calc(var(--space) / 2);
font-size: 1.125rem;
}
.panel p {
margin: 0 0 var(--space);
}
.action {
color: var(--color-action);
}
Both color pairs were checked for contrast: dark blue on white and light blue on near-black are both well above the 4.5:1 that WCAG asks for normal text. Tokens make that check manageable, because there are only a few pairs to verify instead of every rule.
This site is built the same way. One file,
tokens.css, holds all colors, fonts, type sizes,
spacing steps and the line length (--measure: 70ch);
every other stylesheet uses only those custom properties. The light
and dark themes are two sets of values for the same color tokens.
Dark Mode and Theming
covers how such a theme switch works.
calc() and CSS math
calc() computes a value
from an expression with +, -,
* and /, and can mix units that only the
browser can combine, because it knows the layout:
width: calc(100% - 2rem) is "the full width minus
2rem", whatever that is in pixels. You can multiply or divide by a
plain number, which makes it the natural partner of custom
properties: calc(var(--space) * 3).
A common pattern passes a number from the HTML into the CSS with a
custom property in the style attribute, and turns it
into a length with calc():
<p>Monthly energy budget: 72% used</p>
<div class="bar" style="--used: 72" aria-hidden="true">
<div class="bar__fill"></div>
</div>
.bar {
max-width: 20rem;
height: 1rem;
border: 1px solid #555;
}
.bar__fill {
width: calc(var(--used) * 1%); /* 72 becomes 72% */
height: 100%;
background: #1f6f43;
}
The visible text carries the information; the bar is decoration,
hidden from screen readers with aria-hidden="true".
(For a real gauge, the HTML <meter> element
carries the meaning by itself.)
calc() has relatives: min(),
max() and clamp() (see
Responsive Design), and newer functions such as round() and
trigonometric functions. Inside all of them you can use plain
arithmetic without writing calc() again.
Registered custom properties
A normal custom property is just text to the browser: it does not
know whether --angle holds an angle, so it cannot
animate it smoothly or check its value. The
@property rule registers a custom property with a type,
an initial value and whether it inherits:
@property --progress {
syntax: "<percentage>";
inherits: false;
initial-value: 0%;
}
An invalid value now falls back to the initial value, and
transitions on --progress animate smoothly. As of
October 2026 @property is Baseline newly available
(since July 2024), so very old browser versions ignore it; the
property then simply behaves as an unregistered one.
CSS nesting
CSS nesting lets you write rules
inside other rules, so styles that belong to one component stay
together and the parent selector is not repeated. The nesting
selector & stands for the parent selector:
& h3 inside .card means
.card h3, and &:hover means
.card:hover. Media queries can be nested too.
<article class="card">
<h3>Invoice October 2026</h3>
<p>Amount due: €84.20, collected on 28 October.</p>
<a href="#">Download invoice (PDF)</a>
</article>
.card {
max-width: 22rem;
padding: 1rem;
border: 1px solid #555;
& h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
}
& p {
margin: 0 0 0.5rem;
}
& a {
color: #1f4e79;
}
& a:focus-visible {
outline: 3px solid #1f4e79;
outline-offset: 2px;
}
&:hover {
border-color: #1f4e79;
}
@media (width >= 30rem) {
padding: 1.5rem;
}
}
Native nesting became Baseline newly available in December 2023 and
widely available in June 2026. Current browsers also accept a nested
rule that starts with an element name and no
& (h3 { … }), but writing the
& keeps the intent obvious.
The :is(), :where() and :has() selectors
:is() and :where()
:is() matches an element that matches any selector in
its list, which shortens long selector lists. :where()
does the same. The difference is
specificity: :is() takes the specificity of its most specific
argument, while :where() always counts as zero.
/* Without :is() */
article h2, article h3, aside h2, aside h3 { line-height: 1.2; }
/* With :is(): the same elements */
:is(article, aside) :is(h2, h3) { line-height: 1.2; }
/* Defaults that any class can override: specificity (0, 0, 0) */
:where(ul[class], ol[class]) { list-style: none; padding: 0; }
:where() is popular in resets and component libraries,
exactly because it adds no weight: a user of the library can
override it with a single class. Both selectors are also forgiving:
if one selector in the list is invalid or unknown, the others still
work, while in a normal comma-separated list one bad selector makes
the browser drop the whole rule. Both have been widely available
since 2023.
:has(), the parent selector
For most of CSS's history, selectors could only look down and
forward: from an element to its descendants and later siblings.
:has() looks the other
way. It matches an element that contains something matching
its argument: .card:has(img) is a card that contains an
image, and label:has(+ input:invalid) is a label
directly followed by an invalid field. Effects that used to need
JavaScript are now a few lines of CSS. Here the whole option is
highlighted when its radio button is checked; click the options to
try it:
<fieldset class="choices">
<legend>Choose a tariff</legend>
<label class="option">
<input type="radio" name="tariff" value="fixed" checked>
Green Fixed: one price for three years
</label>
<label class="option">
<input type="radio" name="tariff" value="flex">
Green Flex: a new price every month
</label>
<label class="option">
<input type="radio" name="tariff" value="dynamic">
Dynamic: the market price per hour
</label>
</fieldset>
.choices {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
border: 0;
}
.option {
display: block;
padding: 0.75rem;
border: 2px solid #767676;
}
/* The label that contains the checked radio */
.option:has(input:checked) {
border-color: #1f4e79;
background: #e3ecf7;
}
/* Show keyboard focus on the whole option */
.option:has(input:focus-visible) {
outline: 3px solid #1f4e79;
outline-offset: 2px;
}
The native radio buttons stay in place, so the options remain
keyboard-operable (arrow keys move between them) and screen readers
announce them correctly; the CSS only adds a visual layer.
:has() became Baseline newly available in December 2023
and widely available in June 2026. Like :is(), it takes
the specificity of its most specific argument.
Feature queries with @supports
A feature query applies CSS
only if the browser supports a given property and value, or a
selector. It is written as an @supports rule:
/* A property and value */
@supports (grid-template-rows: subgrid) {
.plan { grid-template-rows: subgrid; }
}
/* A selector */
@supports selector(:has(a)) {
.menu:has(.is-open) { border-color: #1f4e79; }
}
/* The opposite */
@supports not (container-type: inline-size) {
.sidebar .card { flex-direction: column; }
}
Feature queries support progressive enhancement:
build a version that works everywhere, then add improvements that
only browsers with the feature receive. Often you do not even need
@supports, because a browser ignores a declaration it
does not understand and keeps the previous one. Writing the fallback
first and the modern value second does the job:
.hero {
min-height: 100vh; /* every browser */
min-height: 100svh; /* browsers that know svh use this one */
}
Use @supports when several declarations belong
together, or when the fallback needs rules that the modern version
must not get. This preview shows a different sentence depending on
whether your browser supports :has(); as of 2026 every
current browser shows the second one:
<p class="support no">Your browser does not support :has(). The tariff options still work, without the highlight.</p>
<p class="support yes">Your browser supports :has(), so the selected tariff is highlighted.</p>
.support.yes {
display: none;
}
@supports selector(:has(a)) {
.support.yes { display: block; }
.support.no { display: none; }
}
Checking browser support with Baseline
Before using a newer feature you need to know whether your visitors' browsers support it. Baseline is a shared label, defined by the W3C's WebDX Community Group and shown on MDN, caniuse.com and web.dev, that answers this per feature. It looks at the core browser set: Chrome, Edge, Firefox and Safari, on desktop and on mobile.
- Limited availability: at least one core browser does not support the feature yet.
- Newly available: the current versions of all core browsers support it. The label "Baseline 2024" means it became newly available in 2024.
- Widely available: 30 months have passed since it became newly available, so most people have browser versions that support it.
Why look at all four engines rather than the most popular browser? In September 2026 Chromium-based browsers (Chrome, Edge, Samsung Internet, Opera) together had about 76% of worldwide browser use, Safari about 18% and Firefox about 3% (StatCounter, all platforms). A feature that works "everywhere except Safari" fails almost one visitor in five. Which level you need depends on your audience: a public service such as the Kestrel customer portal usually sticks to widely available features, or uses newer ones only as progressive enhancement.
Status of features on these pages
| Feature | Status | Since |
|---|---|---|
Custom properties, calc(),
@supports
|
Widely available | Years ago |
:is(), :where() |
Widely available | July 2023 |
| Container size queries | Widely available | August 2025 |
| Subgrid | Widely available | March 2026 |
Nesting, :has() |
Widely available | June 2026 |
@property, light-dark(),
text-wrap: balance
|
Newly available | 2024 |
@scope |
Newly available | March 2026 |
| Container style queries | Newly available | May 2026 |
| Anchor positioning | Limited as a whole (core parts work in all four engines since January 2026) | – |
if(), @function |
Limited (Chromium-based browsers only) | – |
Browser support changes every few weeks, so treat this table as a
snapshot and check the Baseline badge on the feature's MDN page
before you rely on it. Older tutorials are often out of date in the
other direction: they still say you need Sass for nesting or
JavaScript for :has(), or call container queries
"Chrome only".