TechEarl

The CSS :has() Parent Selector: A Complete Guide

CSS :has() is the parent selector we waited 20 years for: style an element based on what it contains. Now Baseline in every engine. Form rows, quantity queries, the previous-sibling trick, and the @supports gate for old browsers.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
CSS :has() is the relational parent selector that styles an element based on its descendants. Baseline in all engines: form-state rows, quantity queries, the previous-sibling trick, and the @supports gate.

For about twenty years CSS had no parent selector. You could style a child based on its parent all day, but you could never reach back up and style an element because of what it contained. :has() is that missing piece: a relational pseudo-class that selects an element when a descendant (or following sibling) matches. The headline you came for:

css
/* Style the .card only when it contains an <img> */
.card:has(img) {
  padding-top: 0;
}

Read that as "a .card that has an img inside it." The element that gets styled is the .card, not the img. That is the whole trick, and it is why people called this the parent selector for years before it shipped.

The bigger news in 2026 is that this is not experimental anymore. :has() is Baseline and production-safe in every engine: Chrome and Edge since 105, Safari since 15.4, and Firefox since 121 (December 2023, the last engine to land it). Internet Explorer never supported it and is retired, so it is not a concern. If you read an older tutorial framing :has() as "Safari is first, will the others follow?", that question was answered years ago. You can ship it today, and the only caveat is the small slice of users on browsers older than those versions, which I cover at the end with @supports.

The basic shape: parent:has(child)

The general form is subject:has(condition). The element matched is the subject on the left; the condition inside the parentheses is evaluated relative to it.

css
/* A figure that contains a figcaption */
figure:has(figcaption) {
  margin-bottom: 2rem;
}

/* A label immediately followed by a required input */
label:has(+ input:required)::after {
  content: " *";
  color: crimson;
}

The condition is a full relative selector, so combinators work inside it. :has(img) means "has an img anywhere in the subtree." :has(> img) means "has an img as a direct child." :has(+ p) looks at the next sibling, not a descendant at all (more on that below). This relative-selector flexibility is what makes :has() so much more than a parent selector.

Styling a form row that contains an invalid input

This is the use case that sells :has() to most people. You want the whole field row, the label, the help text, the border, to react when the input inside it is invalid, and you want to do it without a line of JavaScript.

css
.field:has(input:user-invalid) {
  --border: crimson;
}

.field:has(input:user-invalid) label {
  color: crimson;
}

.field:has(input:user-invalid) .error-text {
  display: block;
}

Note that I reached for :user-invalid rather than :invalid. Plain :invalid matches from the moment the page loads, so an empty required field is "invalid" before the user has typed anything, and the whole form lights up red on first paint. :user-invalid only matches after the user has interacted with the field, which is the behavior you actually want. Pairing it with :has() gives you a row-level error state that appears at the right moment. I go deeper on the full state map in styling form validation in CSS; :has() is what lets those states style the container rather than just the control.

Quantity queries: styling based on how many children there are

Because the condition can use structural pseudo-classes like :nth-child(), :has() lets you ask "does this list have at least N items?" and restyle accordingly. This is the long-requested quantity query.

css
/* A list with 6 or more items switches to a two-column grid */
ul:has(> li:nth-child(6)) {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

li:nth-child(6) only exists if there is a sixth list item, so ul:has(> li:nth-child(6)) is true precisely when the list has six or more children. You can build the same idea with :nth-last-child to count from the end, or combine conditions for an exact range. It is a genuine layout query driven purely by content count, with no JavaScript counting elements for you.

Combining :has() with :not()

:has() composes with the other logical pseudo-classes, and the most useful pairing is :not(). "Style the element when it does not contain something" is a common need, and the naive :not(:has(...)) reads exactly the way you would say it out loud.

css
/* A card with no image gets a text-only treatment */
.card:not(:has(img)) {
  padding: 2rem;
  font-size: 1.125rem;
}

/* A fieldset where every input is valid (none is invalid) */
fieldset:not(:has(:invalid)) button[type="submit"] {
  opacity: 1;
  pointer-events: auto;
}

That second rule is worth pausing on: a submit button stays enabled only while the fieldset contains no invalid control, expressed entirely in CSS. You can stack the relationship the other way too. form:has(input:focus) styles a form while any field inside it is focused, which overlaps with :focus-within. :focus-within is shorter and clearer for the plain "a child is focused" case; reach for :has(:focus) when you need to combine that condition with others.

The previous-sibling trick with :has(+)

CSS has long had the next-sibling combinator (h2 + p styles a p that follows an h2), but never a previous-sibling selector. :has() gives you one, by flipping the subject.

css
/* A heading that is immediately followed by a paragraph */
h2:has(+ p) {
  margin-bottom: 0.25rem;
}

/* A heading followed somewhere by a figure */
h2:has(~ figure) {
  scroll-margin-top: 4rem;
}

h2:has(+ p) selects the h2 based on what comes after it. Before :has(), styling an element based on a later sibling was impossible in pure CSS; you reordered markup or added a class with JavaScript. Now it is one selector.

Performance: it is not the footgun people feared

When :has() was first proposed, the worry was that a relational selector forces the engine to re-evaluate ancestors on every DOM mutation, and that this would be ruinously slow. That fear shaped a decade of "we can never have a parent selector" lore. Modern engines invalidate far more surgically than the naive model assumed, and in practice :has() is fast enough for normal UI work. A couple of habits keep it that way:

  • Anchor the subject tightly. .card:has(> img) is cheaper to evaluate than a bare :has(img) floating at the top of a deep tree, because the subject narrows the candidate set before the relational check runs.
  • Avoid extremely broad subjects combined with deep descendant conditions on very large documents. This is the rare case where it can matter; for typical component-sized trees it does not.

One real-world gotcha that is about layout, not selector cost: if a :has() rule changes layout based on content that loads late (an ad slot, a lazy image, a fetched widget), the layout shifts when that content arrives, which hurts your Cumulative Layout Shift score. The fix is the usual one: reserve the space up front (set a min-height or aspect-ratio on the slot) so the :has() rule is styling a box whose dimensions were already accounted for.

What :has() cannot do

Two limits are worth committing to memory, because both are spec rules, not bugs:

  • You cannot nest :has() inside :has(). div:has(.a:has(.b)) is invalid. If you need that logic, restructure the selector or split it across rules.
  • You cannot put a pseudo-element inside :has(), and a pseudo-element cannot be the anchor of a :has(). Things like :has(::before) are not valid, because pseudo-elements can exist conditionally on their ancestor's styling, which would let you query a thing into and out of existence in a loop.

There is one more sharp edge that trips people up. :has() is a non-forgiving selector list: if any single selector inside the parentheses is invalid (or unsupported), the entire :has() rule is thrown out. This is different from :is() and :where(), which are forgiving and quietly drop only the bad selector. The change was deliberate (a forgiving :has() broke jQuery's selector engine in late 2022). The practical consequence: if you want forgiving behavior inside :has(), wrap the risky part in :is():

css
/* If svg:undefined were invalid, a bare list would kill the whole rule.
   :is() inside :has() makes the inner list forgiving again. */
div:has(:is(img, video, picture)) {
  border: 1px solid;
}

On specificity: :has() takes the specificity of the most specific selector in its argument, exactly like :is() and :not(). So .card:has(#promo) carries the weight of an ID selector, which can be surprising. Keep the arguments as low-specificity as the logic allows.

Gating :has() for old browsers with @supports

:has() is Baseline, but "Baseline" means broadly available now, not present in every browser ever shipped. If your analytics still show meaningful traffic from browsers older than Chrome 105, Safari 15.4, or Firefox 121, gate the enhancement so those users get a sensible fallback instead of nothing. The tool for testing selector support is the selector() function inside @supports:

css
/* Baseline fallback: applies everywhere */
.field label {
  color: inherit;
}

/* Enhancement: only where :has() is understood */
@supports selector(:has(*)) {
  .field:has(input:user-invalid) label {
    color: crimson;
  }
}

The key detail is @supports selector(...), not the property-value form of @supports. It asks "does this browser understand this selector syntax?" rather than "does it support a property." A browser that does not understand :has() will skip the entire block and keep your baseline styling. This is the right way to ship :has() as a progressive enhancement; for the full pattern see CSS feature queries with @supports. If you are layering several modern selectors, it pairs naturally with :is() and :where() for grouping.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

TagsCSS:has()parent selectorrelational selectorform validationfeature queriesBaseline

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

JavaScript Promises Explained: A Complete Guide

How JavaScript promises actually work: the three states, .then/.catch/.finally, async/await as the default modern style, and the gotchas that bite (the explicit-construction antipattern and the await-in-a-loop serialization trap).

The fetch() API: A Practical Guide

A practical guide to the fetch() API: the request/response model, why response.json() returns a promise, and the one surprise that bites everyone, fetch does not reject on 404 or 500. Plus headers, methods, bodies, credentials, and why fetch is now global in Node 18+.