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.
Timeline of Baseline status. A feature starts with limited availability while some core browsers lack it. When the last core browser ships it, it becomes newly available. Thirty months later it becomes widely available. Example: :has() was newly available on 19 December 2023 and widely available on 19 June 2026. limited availability newly available widely available last core browser ships + 30 months :has() 2023-12-19 2026-06-19 Core browsers: Chrome, Edge, Firefox, Safari (desktop and mobile)
The Baseline stages of one feature. "Newly available" starts on the day the last core browser ships it; "widely available" follows 30 months later.

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

Baseline status as of October 2026 (web-features dataset)
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".