DEV Community

Cover image for Practical Uses for CSS :has() Now That It Works Everywhere
Accreditly
Accreditly

Posted on

Practical Uses for CSS :has() Now That It Works Everywhere

For about twenty years the answer to "how do I style a parent based on its children?" was "you can't, use JavaScript". Then :has() arrived, and it has now been supported in every major browser for long enough that you can use it without a second thought. The interesting question is no longer whether you can use it, but what it is actually good for. Here are the patterns I keep reaching for.

Style the wrapper, not just the input

Form fields are usually an input wrapped in a container with a label, a hint and an error slot. Before :has(), styling that container based on the input's state meant toggling classes with JavaScript. Now it is one selector:

.field:has(input:focus-visible) {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}

.field:has(input:user-invalid) {
  border-color: #dc2626;
}

.field:has(input:user-invalid) .hint {
  color: #dc2626;
}
Enter fullscreen mode Exit fullscreen mode

Note :user-invalid rather than :invalid. Plain :invalid matches a required field the moment the page loads, before anyone has typed anything, so your form opens in a wall of red. :user-invalid only matches after the user has interacted with the field, which is what you actually wanted all along.

Cards that adapt to their own content

A card grid where some cards have images and some do not usually ends up with a .card--has-image modifier class that someone has to remember to apply. The card can just check for itself:

.card {
  padding: 1.5rem;
}

.card:has(> img) {
  padding: 0;
}

.card:has(> img) .card-body {
  padding: 1.5rem;
}
Enter fullscreen mode Exit fullscreen mode

The CMS or the component can stop caring. If an editor adds an image, the layout adjusts. If they remove it, the padding comes back.

Styling the previous sibling

CSS has always been able to style the next sibling with +, but never the previous one. :has() fixes that through a small trick: select the element whose next sibling is hovered.

li:hover {
  transform: scale(1.05);
}

li:has(+ li:hover) {
  transform: scale(1.02);
}
Enter fullscreen mode Exit fullscreen mode

The hovered item grows, and the item before it grows slightly too. You get the smooth dock-style ripple effect that previously needed a JavaScript mouseover handler.

Page-level state without JavaScript

Because :has() can sit on body or html, a checkbox anywhere in the document can drive layout anywhere else:

body:has(#menu-toggle:checked) {
  overflow: hidden;
}

body:has(#menu-toggle:checked) .drawer {
  translate: 0;
}
Enter fullscreen mode Exit fullscreen mode

A hamburger menu that locks page scroll and slides a drawer in, with the checkbox hack doing the state management and zero script. For anything more complex than a toggle you will still want JavaScript, but for a mobile nav this is genuinely all it takes.

Quantity queries

:has() can ask how many children an element contains, which is brilliant for layouts that should change shape as content grows:

ul:has(> li:nth-child(6)) {
  columns: 2;
}
Enter fullscreen mode Exit fullscreen mode

Read it as: if this list contains a sixth item, split into two columns. The same idea handles galleries that switch grid density once they pass a threshold, or a tag list that shrinks its font when it gets crowded. No resize observers, no counting nodes in script.

The gotchas

Three things to know before you sprinkle this everywhere.

First, specificity. :has() takes the specificity of the most specific selector in its argument list, so .card:has(#featured) carries id-level weight and will win fights you did not expect it to.

Second, you cannot nest :has() inside :has(). The browser will drop the rule.

Third, unlike :is() and :where(), the selector list inside :has() is not forgiving. One invalid or unsupported selector in the list invalidates the whole rule, so keep experimental selectors out of shared argument lists.

Performance-wise, browsers have done serious optimisation work here. Sensible, qualified selectors are fine in production. Just avoid pathological things like div:has(*) on enormous documents.

Recap

:has() turns a pile of state-management JavaScript into declarative CSS: form field states, content-aware cards, previous-sibling effects, page-level toggles and quantity queries. Combined with :user-invalid it also quietly fixes how form validation feels.

What have you replaced JavaScript with since :has() landed? Share your favourite pattern in the comments below.

Top comments (0)