Browse by section

Web Design 日本語

New Design Patterns with the CSS :has() Selector

:has() is the only CSS selector that lets you target “an element that contains X”. Firefox 121 completed browser support in December 2023, and it is now Baseline.

Calling it “the parent selector” is imprecise. It is a relational pseudo-class that can also select previous siblings and qualifying ancestors. This article covers the syntax, the parts people get wrong, seven patterns that genuinely earn their place, and the pitfalls specific to :has()—specificity and where it is not allowed.

Sponsored

What :has() selects

A:has(B) selects every A for which a matching B can be found. The styles apply to A, not to B.

.container:has(.child) {
  background-color: lightblue;
}

A common misreading is “when it has a child element.” It actually matches any descendant, at any depth. To restrict it to direct children, write the combinator explicitly.

/* any descendant, at any depth */
.container:has(.child) { }

/* direct children only */
.container:has(> .child) { }

/* an element whose next sibling is a p */
h2:has(+ p) { }

/* an element with a matching sibling later in the flow */
h2:has(~ .note) { }

Those + and ~ forms matter: :has() is the only way CSS can select a preceding element.

Browser support

Browser Version Released
Safari 15.4 March 2022
Chrome / Edge 105 September 2022
Firefox 121 December 2023

Pattern 1: styling a parent from an input’s state

This is the highest-value use. Highlighting a selected card becomes pure CSS.

<label class="card">
  <input type="checkbox" name="plan" value="a">
  <span>Standard plan</span>
</label>
.card:has(input:checked) {
  border-color: #2a7;
  background: color-mix(in oklab, #2a7 8%, canvas);
}

.card:has(input:disabled) {
  opacity: .5;
  cursor: not-allowed;
}

The JavaScript that used to listen for change and toggle a class disappears entirely.

Sponsored

Pattern 2: highlighting only the invalid row

Combined with form validation this is very effective. Use :user-invalid, not :invalid, so the whole form does not turn red the instant the page loads.

/* only rows the user has touched and left invalid */
.field:has(:user-invalid) {
  border-left: 3px solid #d33;
  background: #fff5f5;
}

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

/* mark required rows */
.field:has([required]) label::after {
  content: " *";
  color: #d33;
}

You can also gate the submit button:

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

Here :invalid is correct rather than :user-invalid, because you want the button disabled while required fields are still empty. Field appearance uses :user-invalid; submit availability uses :invalid.

Pattern 3: adjusting the margin of a preceding heading

Before :has(), “tighten the heading only when a figure follows” meant adding classes by hand.

/* heading immediately followed by a figure */
h2:has(+ figure) {
  margin-bottom: .5rem;
}

/* consecutive headings */
h2:has(+ h3) {
  margin-bottom: .25rem;
}

/* a paragraph containing only an image */
p:has(> img:only-child) {
  margin: 0;
}

This is most valuable where you do not control the generated HTML—CMS output, rendered Markdown.

Sponsored

Pattern 4: locking background scroll while a modal is open

showModal() makes the background inert but does not stop it scrolling. :has() solves that without JavaScript.

body:has(dialog[open]) {
  overflow: hidden;
}

body:has([popover]:popover-open)::after {
  content: "";
  position: fixed;
  inset: 0;
  background: rgb(0 0 0 / .3);
}

“Find a stateful element anywhere, then style the root” is something only :has() can express. Dialog implementation details are in the HTML dialog element.

Pattern 5: quantity queries

Combining :has() with :nth-child() lets you change layout based on how many children there are.

/* exactly one child: single column */
.grid:has(> :only-child) {
  grid-template-columns: 1fr;
}

/* four or more children (a fourth exists): three columns */
.grid:has(> :nth-child(4)) {
  grid-template-columns: repeat(3, 1fr);
}

Counting items in JavaScript to switch layout is no longer necessary.

Pattern 6: layout depending on whether a sidebar exists

.layout {
  display: grid;
  grid-template-columns: 1fr;
}

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

@media (max-width: 768px) {
  .layout:has(> .sidebar) {
    grid-template-columns: 1fr;
  }
}

No more .has-sidebar class to remember in the template. The markup itself is the condition, so a forgotten class name cannot break the layout.

Pattern 7: combining with :not()

There are two ways to negate :has(), and they mean different things. This is easy to get wrong.

/* 1. cards that do NOT contain an image */
.card:not(:has(img)) {
  padding: 24px;
}

/* 2. cards that contain something other than an image
      (they may also contain images) */
.card:has(:not(img)) {
  /* matches almost every card */
}

For “does not contain X”, always use form 1, :not(:has(...)). Form 2 matches far more than you intend.

Specificity

:has() itself adds no specificity, but the most specific selector inside it is added to the total—the same behaviour as :is() and :not().

Selector Specificity
.card:has(img) 0,1,1
.card:has(.badge) 0,2,0
.card:has(#promo) 1,1,0

An ID inside the argument spikes specificity and makes the rule hard to override later. Do not put ID selectors inside :has().

Where :has() cannot be used

These are invalid or ignored by specification:

  • Nesting :has() inside :has()
  • Attaching it to a pseudo-element: ::before:has(...) is invalid
  • A pseudo-element in the argument: :has(::before) is invalid
  • :visited in the argument: ignored for privacy reasons

Writing :has() on <html> or <body> puts the whole page into the recalculation scope. It works, but avoid pairing it with conditions that change frequently, such as anything tied to pointer movement.

Performance

In normal use, this is not something you will need to worry about. Selector matching is well optimised in browsers.

Two patterns are still worth avoiding:

/* every element becomes a candidate */
*:has(div) { color: red; }

/* far too broad */
div:has(span) { }

/* narrow the anchor */
.card:has(> .badge) { }

The rules are: anchor the left-hand side to a specific class, and use > or + to limit the search where you can. Follow those and you will not measure a problem.

Feature detection

The only browsers lacking support in 2026 are ones you have already dropped. If you still need a guard, use @supports selector().

@supports selector(:has(a)) {
  .layout:has(> .sidebar) {
    grid-template-columns: 1fr 280px;
  }
}

Note it is @supports selector(...), not @supports (...)—testing selector support uses a different syntax.

Summary

  • A:has(B) selects A. Use :has(> B) to restrict to direct children
  • + and ~ let you select a preceding element—unique to :has()
  • Pairing it with :checked and :user-invalid removes a lot of JavaScript
  • “Does not contain” is always :not(:has(...)), in that order
  • An ID inside the argument spikes specificity. Do not do it
  • No nesting, no pseudo-elements, no :visited
  • Detect support with @supports selector(:has(a))

A lot of things you concluded needed JavaScript can now be expressed in CSS.