Vivian Voss

CSS Learns to Think

css web

For twenty-three years, CSS could only look downward.

A parent styled its children. A container styled its contents. Never the reverse. If a form field turned invalid, you could paint the input red. You could not, however, touch the label above it, the fieldset around it, or the submit button below it. Not without reaching for JavaScript, binding an event listener, toggling a class, and hoping nobody refactored the DOM between now and Tuesday.

In 2023, CSS learned to look up, sideways, and around corners. Three pseudo-classes shipped across every major browser within twelve months of each other. Individually, each is useful. Together, they retire entire categories of JavaScript.

:has(): The Parent Selector

The specification calls it a relational pseudo-class. Developers call it the parent selector, because that is what they have been requesting since approximately the Bronze Age of web standards. The idea is disarmingly simple: select an element based on what it contains.

form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

If any field inside the form is invalid, dim the submit button. No event listener. No state variable. No framework. The browser evaluates the condition on every reflow, automatically, at native speed, and entirely without your assistance.

The implications are rather far-reaching. Consider a card component:

.card:has(img) {
  grid-template-rows: auto 1fr;
}

.card:not(:has(img)) {
  grid-template-rows: 1fr;
}

Cards with images get a two-row layout. Cards without get a single row. The layout adapts to the content, not to a class name somebody remembered to add at half past five on a Friday.

Or consider what was previously the domain of JavaScript entirely: a label that responds to its sibling input's validity:

label:has(+ input:invalid) {
  color: oklch(55% 0.25 25);
}

The + adjacent sibling combinator inside :has() gives CSS something it never had: the ability to style an element based on what comes after it. The label precedes the input in the DOM. Previously, only JavaScript could bridge that gap. Now the stylesheet handles it.

Before 2023 form label input button Downward only With :has() form label input ❌ button Up, sideways, through Traditional cascade :has() selection

:is(): The Grouper

Before :is(), grouping selectors meant writing out every combination by hand. The combinatorial explosion was real and, frankly, tedious:

/* Before: six selectors, three shared properties */
article h1, article h2, article h3,
section h1, section h2, section h3 {
  line-height: 1.2;
}

After:

:is(article, section) :is(h1, h2, h3) {
  line-height: 1.2;
}

One line. Same result. :is() groups selectors without the cartesian product. Two parents, three headings, one rule, rather than six selectors pretending they are not the same thought repeated with minor variations.

There is, however, a subtlety worth noting: specificity. :is() adopts the specificity of its most specific argument. This matters:

:is(#hero, .card) p {
  /* specificity: 1,0,1 — the #id wins */
}

Every .card p in that rule inherits the specificity of #hero. If that surprises you, you are not alone. If it does not surprise you, you have either read the specification or been burnt by it. Possibly both.

:where(): The Reset Tool

Identical syntax to :is(). Identical grouping behaviour. One critical difference: specificity is always zero.

:where(article, section) :is(h1, h2, h3) {
  line-height: 1.2;
}

This rule applies, but any other rule overrides it without a fight. No !important. No specificity arms race. No increasingly desperate selector chains. The rule simply yields.

This is precisely what you want for defaults, resets, and base layers. Write the sensible fallback. Let the specifics win. If you have ever wondered how @layer and zero-specificity selectors complement each other: this is how. :where() provides zero-weight defaults within a layer. @layer controls which layers override which. Together, they make the cascade predictable rather than adversarial.

The Combination

Individually, each pseudo-class solves a class of problem. Combined, they become rather more interesting:

:where(.card):has(:is(img, video)) {
  aspect-ratio: 16/9;
  overflow: hidden;
}

Translation: any card containing an image or video gets a 16:9 ratio. :where() keeps the specificity at zero so component-level styles override freely. :has() inspects the children. :is() groups the media types. Three pseudo-classes. One declaration block. Zero JavaScript. Zero specificity conflicts.

Specificity of grouped selectors :is(#id, .class) 1,0,0 ← adopts #id :where(#id, .class) 0,0,0 ← always zero Direction of selection parent → child Traditional CSS (23 years) child → parent :has() (since 2023)

What This Replaces

Before :has(), styling a parent based on child state required:

  • A JavaScript event listener
  • A state variable or class toggle
  • A re-render or DOM mutation
  • A framework, quite possibly

Four moving parts to achieve what one CSS selector now handles natively. The browser checks the condition on every reflow. It does so in compiled C++, not interpreted JavaScript. It does so without your build step, your bundler, or your state management library. It does so, in fact, for free.

Consider a practical set of patterns that previously required JavaScript, every one of which is now pure CSS:

/* Form validation without JS */
form:has(:invalid) .submit {
  opacity: 0.5;
  pointer-events: none;
}

/* Dark mode via checkbox toggle */
html:has(input#dark:checked) {
  color-scheme: dark;
}

/* Empty state message */
ul:not(:has(li))::after {
  content: "No items yet.";
}

The dark mode toggle is particularly instructive. An <input type="checkbox"> somewhere in the DOM, a single :has() on <html>, and the entire page switches colour scheme. No JavaScript. No localStorage. No flash of unstyled content. The checkbox state is the application state. CSS reads it directly.

The Deeper Point

These three pseudo-classes are not incremental improvements. They are a phase change. For twenty-three years, CSS was a one-way cascade: parent to child, top to bottom, general to specific. That constraint forced an entire generation of developers to reach for JavaScript whenever the styling direction needed to reverse.

That constraint no longer exists.

:has() sits at 91% global browser support. :is() and :where() are at 98% each. Every modern browser has shipped all three since December 2023. There is no polyfill required. There is no build step. There is no bundle size. There is a selector.

The language that styles the web can finally reason about the structure of the web. Twenty-three years late, perhaps. But better late than another npm install.

The best JavaScript is the JavaScript you did not write. CSS just made quite a lot of it unnecessary.