Quick Answer

:has() lets a CSS selector match a parent — or an earlier sibling — based on what's inside it or what comes after it, something CSS genuinely could not do for its first 25-plus years without JavaScript. It reached full support across Chrome, Safari, Firefox and Edge by the end of 2023, and Baseline tracks it reaching 'widely available' status in the middle of 2026, so you can reach for it directly for most real-world audiences today.

What :has() Actually Does

:has() is a relational pseudo-class — informally, people call it the "parent selector" because the most common use is matching an element based on its descendants, something no other CSS selector could express. The syntax wraps a relative selector inside the parentheses:

selector:has(relative-selector) { /* styles */ }

The selector inside :has() can use combinators directly, without restating the subject:

.card:has(img) { }      /* .card containing an img ANYWHERE inside it */
.card:has(> img) { }    /* .card where img is a DIRECT child only */

You can also combine multiple conditions. Comma-separated selectors inside :has() work as OR logic — match if any one of them is present:

.card:has(img, video) { } /* matches a card with EITHER an img OR a video inside */

And chaining separate :has() calls works as AND logic — every one has to match:

.card:has(img):has(.badge) { } /* matches a card with BOTH an img AND a .badge inside */

Before this shipped, expressing any of the above required JavaScript — querying the DOM, checking for a child's presence, and toggling a class on the parent as a side effect. :has() turns that into a single declarative CSS rule with no runtime check needed at all.

It's also worth being precise about what "relative selector" means here, because it trips people up coming from :is() or :where(). Inside those two, a bare selector list works exactly like it would anywhere else — :is(a, button) just means "an a or a button." Inside :has(), a bare selector like img is interpreted as an implicit descendant combinator — the same as the plain descendant selector elsewhere in CSS, meaning "has a descendant that is an img," not "has a direct child that is an img." That's exactly why you need the explicit > to restrict it to direct children only, and why :has(+ p) and :has(~ p) mean something different again: "has a following sibling that is a p," not "contains a p."

The Classic Use Case: Style a Parent By What's Inside It

A common real pattern: a card component should lay out differently depending on whether it has an image. Without :has(), you'd need JavaScript running on mount (and again on any dynamic update) to check for the image and toggle a class:

// the old way — a runtime check just for a presentational rule
document.querySelectorAll('.card').forEach(card => {
  if (card.querySelector('img')) {
    card.classList.add('has-image');
  }
});

With :has(), the same logic is a CSS rule, evaluated automatically whenever the DOM changes, with no JavaScript involved at all:

.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
}
.card:not(:has(img)) {
  display: block;
}

The difference isn't just fewer lines of code. The JavaScript version only re-runs the check when you remember to call it again — add an image to a card dynamically after the initial render, and the class never updates unless that code path explicitly re-checks. The CSS version is always correct, because the browser re-evaluates selectors as part of normal style recalculation whenever the DOM it's watching actually changes — there's no separate state to fall out of sync.

This generalises past images: a sidebar that reserves space for an icon only :has(.icon), a navigation item that gets a dropdown caret only :has(ul), a list item that changes padding only :has(> .nested-list). Any "does this container have X inside it" rule that used to need a class toggle is a candidate.

A second, less obvious version of the same pattern is styling based on count, not just presence. Combining :has() with the general sibling combinator lets you detect "this list has only one item" versus "this list has several," entirely in CSS:

/* A list with exactly one item — no second li anywhere after the first */
li:only-child { }

/* A card grid with more than one card gets a gap; a single card doesn't need one */
.grid:has(.card ~ .card) {
  gap: 1rem;
}

.card ~ .card only matches a .card that has another .card somewhere before it as a sibling — which is only possible if there are at least two. Wrapping that in :has() turns "does this container have 2 or more of this child" into a single selector, no JavaScript counting children.length required.

Styling Forms by Validity State, No JavaScript

HTML forms already expose validity through pseudo-classes like :invalid, :valid, and :required, but before :has() there was no way to style a wrapper based on the validity of the input inside it — you could style the input itself, not its label or container.

.field:has(:invalid) {
  border-color: var(--err);
}

.field:has(input:focus) {
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
}

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

That last rule is a genuinely common pattern — disable the submit button until every field in the form is valid — expressed entirely in CSS, reacting live as the user types, with zero JavaScript event listeners. It's worth being precise about what this replaces: it's a UX improvement, not a validation strategy on its own. A disabled-looking submit button is still just styling; you still need real server-side validation, since nothing about a CSS rule stops a request from reaching your backend some other way (a direct API call, a modified form, a user with JavaScript disabled in a way that defeats your UI layer). Use :has() here to make the form feel responsive, not as your only validation layer.

One more detail matters for this exact pattern: :invalid applies the moment a field's value doesn't satisfy its constraints — which, on a fresh empty required field, is immediately, before the user has typed anything at all. That means a naive form:has(:invalid) rule shows every field as invalid-looking the instant the page loads, which is worse than no validation styling at all. The usual fix is scoping the rule to fields the user has actually interacted with, using :user-invalid where it's supported, or simply gating the whole block behind a "form was submitted once" class the page adds after a first attempt — :has() handles the "which fields" question well; it doesn't solve the separate "when should this even start showing" question on its own.

Using :has() With Siblings, Not Just Children

:has() isn't limited to descendants — the relative selector inside it can use the sibling combinators too, which opens up rules that react to what comes after an element, something the + and ~ combinators alone could never do (they only let you style the sibling that comes after a match, never the one before it).

/* Add extra spacing under a heading, but only when it's
   immediately followed by a lead paragraph */
h2:has(+ p.lead) {
  margin-bottom: 0.25rem;
}

A second trick worth knowing is the "quantity query" — applying a layout rule only once a list has grown past some number of items, purely in CSS:

/* Switch to a denser grid once the list has 5 or more items */
ul:has(> li:nth-child(5)) {
  grid-template-columns: repeat(3, 1fr);
}

This works because :has() only matches when the relative selector finds something — a fifth <li> child only exists once the list actually has five or more items, so the rule simply doesn't apply to shorter lists. No JavaScript counting children, no ResizeObserver, no manual class on the container when it crosses a threshold.

It's worth being precise about which direction this actually works in, because it's easy to overstate. :has() lets an element's styling depend on what comes after it (a heading styled based on the paragraph that follows), but it still can't express "style this element based on what comes immediately before it" — there's no plain-CSS equivalent of jQuery's .prev() that lets a later element reach backwards and select an earlier sibling directly. What you get instead is the mirror image: the earlier element reaching forward. In practice this covers more real layouts than it sounds like it would — spacing under a heading based on what follows it, a border above a closing section based on what's below — because you can usually restate "style B based on A before it" as "style A based on B after it" without losing anything, just by choosing which element in the pair is the subject of the rule.

Finding Truly Empty Elements With :not(:has(*))

CSS already has an :empty pseudo-class, but it's stricter than most people expect: it only matches an element with zero child nodes at all, and per spec that includes whitespace text nodes. A templating engine that renders a container with a newline and some indentation inside it but no real content defeats :empty immediately, because that whitespace counts as a text node.

:has(*) checks for element children specifically, ignoring whitespace and text nodes entirely, so a common community pattern pairs it with :not() to get the "no meaningful content inside" check people actually want:

/* Hide a container that has no element children,
   regardless of stray whitespace left by templating */
.panel:not(:has(*)) {
  display: none;
}

This is a well-known workaround discussed across the CSS community rather than a documented alias for :empty — it checks a genuinely different condition (any element child vs. any child node at all) that happens to line up with what most people actually mean when templated whitespace is involved.

The same building block solves a second, related annoyance: detecting an element that has element children but no text content of its own directly inside it (as opposed to text buried inside those children). A container that wraps nothing but icon elements, versus one that has a stray text node sitting alongside them, is a distinction plain :empty can't make either way — it only has a single yes/no answer for "any child nodes at all." Because :has() lets you compose arbitrary conditions about what's inside an element, you can build increasingly specific versions of "is this effectively blank" without reaching for a single purpose-built pseudo-class that covers every variant, which is really the broader value of :has(): it's less a feature with one headline use case and more a general tool that replaces a whole category of small, one-off JavaScript checks that used to exist only because CSS had no way to look inward.

Browser Support in 2026, and Real Limitations

Support arrived in this order: Safari 15.4 (March 2022), Chrome 105 (September 2022), Firefox 121 (December 2023). The CSS Working Group marked it Baseline "newly available" as of December 2023, with Baseline "widely available" status landing around the middle of 2026 — in practical terms, every major engine has supported it for roughly two to four years at this point.

For the rare older browser in your traffic, wrap usage in a feature query so the fallback is a slightly less polished layout, never a broken one:

@supports selector(:has(a)) {
  .card:has(img) { grid-template-columns: 120px 1fr; }
}

Three real limitations are worth knowing before you reach for it on something load-bearing:

  • No pseudo-elements inside the argument. You can check for a pseudo-class like :checked inside :has(), but you cannot target a pseudo-element like ::before — the spec disallows it deliberately, partly because many pseudo-elements only exist conditionally based on their ancestor's own styling, which would create circular dependencies.
  • No nesting :has() inside another :has(). You can't write :has(:has(a)) directly, though the relational selector list usually lets you express the same intent differently — :has(p a) already tests for a paragraph that itself contains a link, without nesting.
  • :has() is "non-forgiving." Unlike :is() and :where(), if any one selector inside a comma-separated :has() list is invalid, the entire rule is thrown out — not just the invalid part. :has(:invalid-made-up-selector, img, p) matches nothing at all, even though img and p are perfectly valid on their own.

That non-forgiving behaviour is worth taking seriously in a real codebase, not just as trivia. A typo inside a :has() argument list doesn't throw a build error or even a console warning in most setups — the rule just silently stops matching anything, and a card that should have gotten a two-column layout quietly keeps its default one-column layout instead, with nothing in your tooling pointing at the cause. If a :has() rule you're confident should be matching simply isn't doing anything, check every selector in its argument list individually before assuming the logic is wrong — it's often a single invalid clause silently voiding the whole thing.

Does :has() Slow Down Your CSS?

Normal CSS selector matching is built around matching right-to-left: the engine looks at the rightmost part of a selector first (the "subject"), and only walks further left — checking ancestors — if that subject actually matches something. :has() inverts part of that: to know whether .card:has(img) applies to a given .card, the engine has to look forward into that card's subtree to check for a matching descendant, which is a fundamentally different kind of work than the matching model was originally built around.

In practice, this means a selector with a narrow, specific left-hand side — a real class, not a bare tag or a wildcard — stays cheap, while something like *:has(*) deep in a large, frequently mutating DOM is genuinely more expensive to recompute, because the browser has to re-check subtree contents on every relevant mutation, not just re-check a flat attribute.

The practical guidance: scope the subject of a :has() rule as narrowly as you can (a class beats a tag, a tag beats a universal selector), and be cautious about driving a :has()-based rule off something that changes on every keystroke across a large number of nodes — a form with five fields reacting to validity is fine; a data grid with thousands of rows re-triggering a :has() check on every render is a different risk profile. Browser engines have put real optimisation work into the common cases since shipping it, but it isn't the same near-zero cost as a plain class selector, and treating it that way on a large, mutation-heavy page is the one place this feature can genuinely bite you.

If you're unsure whether a specific :has() rule is costing you anything measurable, the check is the same one you'd use for any other CSS performance question: open DevTools' Performance panel, record a scroll or interaction on the real page, and look at the time spent in "Recalculate Style" and "Layout." A spike there correlated with the exact interaction that triggers your :has() rule's subtree to change is the signal to narrow the selector further — scope it to a smaller container, add a class to reduce how often the DOM mutation underneath it actually happens, or reconsider whether the rule needs to be reactive to every keystroke at all versus only on blur or submit.

Frequently Asked Questions

Can I use :has() to check for a pseudo-element like ::before?

No. The spec deliberately disallows pseudo-elements as selectors inside :has(), partly because many pseudo-elements exist conditionally based on their own ancestor's styling, which would create circular evaluation. You can still check pseudo-classes like :checked or :focus inside it — just not pseudo-elements.

Can I nest :has() inside another :has()?

Not directly — :has(:has(a)) isn't valid. In most cases you can express the same intent without nesting, since the relative selector inside :has() can itself contain a descendant combinator: :has(p a) already tests for a paragraph that contains a link.

What happens if one selector in a comma-separated :has() list is invalid?

The whole rule is thrown out, not just the invalid part. Unlike the 'forgiving' selector lists in :is() and :where(), :has() requires every selector in the list to be valid for the rule to apply at all — one typo silently disables the entire rule.

Do I need a JavaScript fallback for browsers that don't support :has()?

For most audiences in 2026, no — support has been in every major engine for two to four years. For the rare older browser, wrap the rule in @supports selector(:has(a)) {} so unsupported browsers simply get the pre-:has() layout, which should degrade to something reasonable, not broken.

Is :has() the same as a 'parent selector'?

That's the informal name people use for its most common use case, but it's really a broader relational selector — it also works with sibling combinators to check what comes after an element, not only what's nested inside it.

Will using :has() slow down my page?

Rarely in a way you'd notice, as long as the left-hand side of the selector is specific (a class, not a bare tag or *). It's a genuinely different matching model than normal CSS selectors, so avoid pairing a broad :has() rule with a large, frequently-mutating DOM if you want to stay safe.