HTML5 Please

The CSS :has() Selector: What It Does and Where It Is Safe

The CSS :has() Selector: What It Does and Where It Is Safe

For roughly two decades the answer to "can I style a parent based on its children" was no, followed by a JavaScript workaround and a class name. :has() ended that. It is the relational pseudo-class, it has been safe across browsers since the end of 2023, and in 2026 there is no good reason left to avoid it. Here is what it matches, what it does not, and the handful of patterns that pay for themselves immediately.

What it actually matches

:has() takes a selector list and matches an element if any of those selectors matches something relative to it. The element you style is the one on the left, which is why people call it the parent selector, but that name undersells it.

/* a card that contains an image gets a different layout */
.card:has(img) { grid-template-columns: 120px 1fr; }

/* a form containing an invalid field announces itself */
form:has(:invalid) { border-color: #d92626; }

/* a heading that is immediately followed by a paragraph */
h2:has(+ p) { margin-bottom: 6px; }

That third example is the tell. :has() is not limited to descendants. Put a combinator inside and it looks sideways: + for the next sibling, ~ for any later sibling, > for direct children only. Once that lands, the selector stops being a parent trick and becomes a general way to ask about the shape of the DOM around an element.

Patterns worth stealing

Form state without JavaScript. A label that reacts to its own checkbox is the one everybody writes first, and it removes a listener from your bundle.

label:has(input:checked) { font-weight: 600; }
label:has(input:disabled) { opacity: .55; }

Layout that adapts to optional content. Components usually have parts that may or may not be there. Instead of passing a hasSidebar prop down and toggling a class, ask the DOM.

.layout:has(> aside) { grid-template-columns: 1fr 280px; }

Hiding genuinely empty containers. :empty is strict about whitespace and rarely does what you want. The combination is more forgiving.

.panel:not(:has(*)) { display: none; }

Page level state from a single control. Because :has() can sit on html or body, one checkbox or one open dialog can change the whole page without a script.

html:has(dialog[open]) { overflow: hidden; }
html:has(#theme-toggle:checked) { color-scheme: dark; }

That first rule alone replaces the scroll locking code that most modal implementations still carry around.

The specificity rule that surprises people

:has() itself adds nothing to specificity, but its argument does, and it takes the specificity of the most specific selector inside it. So .card:has(#hero) is as specific as an id selector, which is almost never what the author intended. If you find a rule winning fights it should not be winning, look inside the parentheses first.

The same logic applies to :is() and :not(), and the escape hatch is the same: :where() contributes zero specificity, so .card:has(:where(#hero)) keeps the match and drops the weight.

What it cannot do

  • No nesting. :has() cannot contain another :has().
  • No pseudo-elements. You cannot ask whether an element has a ::before.
  • It is not a polyfill target. Selectors run inside the engine, so unlike a missing API this is not something a script can shim convincingly. Anything built on :has() needs to degrade rather than be patched, which in practice means writing the base state so the page is fine without it and letting the rule enhance from there.

The performance question, answered honestly

When :has() was proposed the objection was that it would force the engine to re-evaluate ancestors constantly and wreck style recalculation. That fear shaped a lot of early advice, and it turned out to be largely wrong. Browsers restrict invalidation to the subtree that can actually be affected, and in ordinary page code :has() costs nothing measurable.

The advice that survives is narrow and worth following anyway. Give the selector a subject rather than starting from the universal selector, so .card:has(img) and not *:has(img). Keep the inside of the parentheses shallow, preferring > and + to long descendant chains. And if a rule runs on something that changes many times per second, measure it rather than assuming, which is true of any selector.

Support in 2026

Safari shipped it in March 2022, Chrome and Edge in August 2022, and Firefox in December 2023, which is the date that made it interoperable everywhere. That is now years of coverage across every browser with meaningful share, so :has() sits firmly in the use freely column alongside container queries and gap.

The practical check is not the browser table any more, it is your own analytics. If a measurable slice of your traffic still arrives on something older than Firefox 121, write the base state first and let :has() enhance it. Everyone else can treat it as ordinary CSS. Our selectors page tracks the details, and the MDN reference for :has() covers the full grammar.

Why it matters more than it looks

The interesting part is not that you can finally style a parent. It is how much conditional logic moves out of JavaScript when the stylesheet can ask questions about structure. Scroll locking, empty state handling, optional slots, form validation styling, theme switching: all of these used to require a class toggled by a script, which meant a listener, a render pass and a chance to get out of sync. Now they are a rule. Combined with container queries, which let a component respond to its own space, CSS in 2026 handles a category of problems that used to belong to the framework.