Errors and fixes

Parent selector in CSS: what replaced the JavaScript workarounds

:has() ships in modern browsers and lets you style a container from its children. Two limits remain: dated webviews and the cost on very large trees.

Key points

  • :has() is the native answer to “how do I style the parent from the child”.
  • Support covers Chrome 105+, Safari 15.4+ and Firefox 121+, verifiable on caniuse rather than assumed.
  • On a large DOM a complex :has() costs: measure before using it on lists of thousands of nodes.

Short answer: :has() is the native parent selector. It styles a container from what it holds, with no class added by JavaScript: .card:has(img) { ... }.

How the browser evaluates :has()

A normal selector settles the style of an element by looking at ancestors and siblings. :has() reverses the direction: to know whether .card:has(img) applies, the engine has to look inside the card. Browsers solve this with invalidation: when a node changes, the engine walks up to the ancestors that carry a :has() rule and recomputes only those branches. That is why the feature shipped late: for years the cost of the walk up was judged prohibitive.

Two practical consequences. First, :has() cannot be polyfilled reliably, because it would require watching every node with MutationObserver. Second, the deeper the selector, the more nodes enter the set to invalidate. .card:has(img) looks at direct children; .card:has(.grid .cell img) looks at the whole subtree.

The diagnostic step

Before arguing about support, check what the engine in front of you actually does. Two lines in the console are enough.

CSS.supports("selector(:has(*))");                       // true = il motore capisce :has()
document.querySelectorAll("form:has(:invalid)").length; // quante form corrispondono

If the first line returns false the problem is the browser or the webview, not the stylesheet. If it returns true and the rule still does not show, the selector is evaluated and does not match: inspect the element and check that the child you are looking for is a descendant, not a sibling and not a node inside a shadow root.

What it replaces

BeforeNowGain
class added by JS when the image loads:has(img)no listener, no reflow
form state managed in Reactform:has(:invalid)immediate feedback with no state
different layout when a banner existsbody:has(.banner)one rule, no wrapper

Concrete examples

/* una card con immagine ha un padding diverso */
.card:has(img) {
  padding: 0;
}

/* il bottone di invio resta disabilitato finché il form non è valido */
form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

/* niente margine sotto l'ultimo paragrafo */
.prose > *:last-child:has(+ *) {
  margin-bottom: 1rem;
}

The last example is wrong on purpose: :last-child:has(+ *) cannot exist, because the last child has no following sibling. It is a reminder that :has() does not change selector logic, it extends it.

Support

Chrome 105+, Safari 15.4+ and Firefox 121+. This is not a feature to polyfill: JavaScript polyfills over selectors are fragile and costly. If your audience includes dated webviews, add a class from the JavaScript you already ship and treat :has() as progressive enhancement.

When the fix does not work

Two real limits. First, :has() cannot be used inside a pseudo-element such as ::before. Second, on a very large tree the browser has to evaluate descendants, and a complex selector over thousands of rows can cost. Profile with the Performance panel before applying it to a table.

A third case is a closed shadow DOM: :has() does not cross the boundary. When the component sits in a webview or a shadow root, the selector cannot see the children.

When the common fix makes things worse

The most shared example blocks the submit button with form:has(:invalid) button { pointer-events: none }. It looks elegant and hides the real problem: on first load the form is empty, therefore invalid, therefore the button does not answer. Someone pressing Enter gets neither the browser message nor an error, the click just vanishes.

The opacity: 0.5 variant is less severe and still misleading, because required fields are not marked and the user cannot tell what is missing. If you drive the form state with :has(), pair it with :user-invalid on the fields so the message arrives at the right moment, and never strip the pointer events from a control that has to explain why it is blocked.

One limitation to budget for

The limit written into the specification is nesting: :has() cannot sit inside another :has(). You cannot write .card:has(.media:has(video)); you go through a class or two separate rules. The same family covers the ban on pseudo-elements: ::before has no children, so the selector has no tree to work on.

How to prevent it

  • Use :has() for visual states, not for critical layout logic.
  • Keep selectors short: .card:has(img) beats a five-level chain.
  • Document the fallback for webviews that do not support it.

Where to continue

In the catalogue: hero block, components and blocks. The guides: Flexbox and Grid for designers and UI kit, library or design system; the comparison: accessibility checklist.

Frequently asked questions

Can I ship it in production today?

Yes, if the audience runs current browsers. For dated application webviews you need a fallback class added by JavaScript.

Does it work with pseudo-elements?

No. :has() cannot be used inside a pseudo-element; it applies to a real element.

Does it replace every JavaScript trick?

Most of the ones based on children. It does not cover cases where you must know the content or state of a non-DOM component.

Why does it not update sometimes?

When the change happens on a node outside the observed tree, or inside a closed shadow root, the selector does not see it.

Is it slower than a class?

Yes, because the browser has to evaluate descendants. On a card it costs nothing; on a table with thousands of rows, measure.