Calling :has() a parent selector is useful, but incomplete. It is a relational selector that can respond to a descendant's state without JavaScript. The related element can be a descendant, a direct child, or even the next sibling.

That makes :has() useful anywhere CSS used to need a duplicated class such as .card--with-image, .field--invalid, or .toolbar--active. It does not create state. It lets CSS read state that already exists in the HTML.

Read :has() from the outside in

.card:has(> img) {
  grid-template-columns: 8rem 1fr;
}

Start with .card. That is the element being selected. The argument > img is evaluated relative to each card. A card matches when it has an image as a direct child.

The leading combinator matters. :has(img) accepts an image at any depth. :has(> img) only accepts a direct child. Tight relationships make the selector easier to understand and reduce accidental matches after the markup changes.

1. Adapt a component to the content it actually has

Duplicated modifier

<article class="card card--with-image">
  <img src="cover.webp" alt="">
  ...
</article>

.card--with-image {
  grid-template-columns: 8rem 1fr;
}

State from the HTML

.card:has(> img) {
  grid-template-columns: 8rem 1fr;
}

.card:not(:has(> img)) {
  grid-template-columns: 1fr;
}

The markup now has one source of truth. Removing the image also removes the image layout. This is a small improvement, but it prevents the modifier and the content from drifting apart in templates or CMS output.

2. Style a choice from its checked input

A radio card often needs JavaScript only because the visual wrapper sits above the input in the DOM. Put the input inside the label and let :has() reflect its native checked state.

<label class="plan">
  <input type="radio" name="plan" value="pro">
  <span>Pro</span>
</label>

.plan:has(input:checked) {
  border-color: var(--accent);
  background: var(--mint-soft);
}

.plan:has(input:focus-visible) {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

Keep the real input available to assistive technology. Hiding it with display: none removes it from keyboard navigation. A visually hidden pattern lets the label carry the custom appearance without losing native form behavior.

3. Style an element based on what comes next

CSS can already select the next sibling with +. It could not select the previous one. With :has(), a heading can react to the element immediately after it.

h2:has(+ .lede) {
  margin-block-end: 0.35em;
}

h2 + .lede {
  color: var(--ink-muted);
  font-size: 1.2em;
}

The first rule selects an h2 only when its next sibling has the .lede class. No wrapper or template flag is required.

4. Remove a parent state class

JavaScript may still own the interaction while CSS stops mirroring its state. Suppose a toolbar button already updates aria-pressed. The parent does not need a second .has-active-tool class.

button.addEventListener("click", () => {
  button.ariaPressed = button.ariaPressed !== "true";
  toolbar.classList.toggle("has-active-tool");
});
.toolbar:has([aria-pressed="true"]) {
  border-color: var(--accent);
}

The click handler still changes the button's state because that is application behavior. The parent class disappears because it was only a styling hook. This is the kind of JavaScript reduction :has() does well.

5. Show form feedback after the browser has evidence

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

.field:has(input:user-invalid) {
  border-inline-start: 3px solid var(--danger);
}

:user-invalid differs from :invalid. It waits until the user has interacted with the control and the browser decides validation feedback is appropriate. That avoids painting every required field red when the page first loads.

6. Change a collection when it contains one result

.results {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}

.results:has(> :only-child) {
  grid-template-columns: minmax(0, 36rem);
}

A single result no longer stretches across the whole container. The collection responds to its real child count without a server-side modifier or a script that recounts items after every update.

Specificity and performance deserve attention

The :has() pseudo-class does not add a fixed specificity weight. Its specificity comes from the most specific selector in its argument. An ID inside :has() can therefore make the whole rule unexpectedly difficult to override.

/* Specific because the argument contains an ID */
.card:has(#featured-image) { ... }

/* Zero specificity for the whole selector */
:where(.card:has(> img)) { ... }

Keep selectors local. A component selector such as .card:has(> img) gives the browser a narrow subtree to inspect. A broad selector such as body:has(.something) asks it to watch much more of the document and is harder for another developer to reason about.

Two hard limits: you cannot nest :has() inside another :has(), and pseudo-elements are not valid inside its argument. These restrictions prevent selector cycles.

Use fallbacks where the state changes meaning

Modern browsers support :has(), but a fallback can still help if your browser policy includes older versions. Start with a usable base layout, then add the relational rule inside a feature query.

.card {
  display: block;
}

@supports selector(.card:has(> img)) {
  .card:has(> img) {
    display: grid;
    grid-template-columns: 8rem 1fr;
  }
}

A missing decorative layout is a good progressive enhancement. Missing error feedback is not. When the rule communicates validation, selection, or another important state, provide an equivalent fallback or keep the existing state class until your supported browser set can use :has() safely.

Primary references