Back to Blog

CSS :has(): UI Logic Without JavaScript

The State-Management Gap

Common UI patterns have required JavaScript for years, not because the logic is complex, but because CSS had no way to select an element based on what it contains. Tab highlighting required class toggling. Form field error states required DOM manipulation. Card layout variants required data attributes set from scripts. The logic belonged to CSS, but CSS lacked the selector.

:has() is that selector. It reached Baseline Widely Available in December 2023 when Firefox 121 shipped support. Global coverage exceeds 95% in 2026. Production-safe, no polyfill required. This is the fourth post in the native web series — the previous three covered <dialog> for modals, the Popover API for menus, and native form validation.

What :has() Does

:has() selects an element if any selector in its argument matches a descendant (or sibling, depending on the combinator used). The readable framing: select the ancestor if it has a child that matches.

/ Select a label that contains a required input /
label:has(input[required]) {
  font-weight: 600;
}

/ Select a card that contains an image /
.card:has(img) {
  grid-template-columns: 200px 1fr;
}

/ Select a form that contains any invalid field /
form:has(:user-invalid) {
  border-color: #dc2626;
}

The selector inside :has() is evaluated relative to the element it is applied to. form:has(:user-invalid) reads: a <form> that has at least one descendant in the :user-invalid state. The state was already in the DOM. :has() makes it visible to CSS.

Pattern 1: Form Field Highlighting

Style the entire form row when its input is focused or invalid — without a single event listener:

.field:has(input:user-invalid) {
  background-color: #fef2f2;
  border-radius: 4px;
}

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

.field:has(input:focus) {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

Before :has(), this pattern required JavaScript: attach focus and blur listeners to each input, call .closest('.field').classList.add('is-focused') on the parent, write CSS against that class, re-attach listeners when components remounted. Class names drifted between the JavaScript and the stylesheet. :has() removes that entire layer. The input's focus state and validity state are already in the DOM — CSS can read them directly.

Pattern 2: Conditional Card Layout

A card that displays differently depending on whether it includes an image is a classic case where the layout logic belonged in CSS but had no way to get there.

.card {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.5rem;
}

/ Two-column layout when the card has an image /
.card:has(img) {
  display: grid;
  grid-template-columns: 200px 1fr;
  align-items: start;
}

No modifier class, no data attribute, no JavaScript. Add an image to the card markup and the layout switches. Remove it and the card returns to stacked. The CSS reads the DOM directly.

Pattern 3: Tab Indicator

Tab navigation that highlights the active section typically requires JavaScript to track which tab is selected and toggle an .active class. With :has(), a radio-button tab pattern becomes pure CSS:

<div class="tabs">
  <input type="radio" id="tab-1" name="tabs" checked hidden>
  <input type="radio" id="tab-2" name="tabs" hidden>
  <input type="radio" id="tab-3" name="tabs" hidden>

  <nav>
    <label for="tab-1">Overview</label>
    <label for="tab-2">Details</label>
    <label for="tab-3">Reviews</label>
  </nav>

  <div class="panel" id="panel-1">Overview content</div>
  <div class="panel" id="panel-2">Details content</div>
  <div class="panel" id="panel-3">Reviews content</div>
</div>
/ Hide all panels by default /
.panel { display: none; }

/ Show the correct panel based on the checked radio /
.tabs:has(#tab-1:checked) #panel-1 { display: block; }
.tabs:has(#tab-2:checked) #panel-2 { display: block; }
.tabs:has(#tab-3:checked) #panel-3 { display: block; }

/ Highlight the active tab label /
.tabs:has(#tab-1:checked) label[for="tab-1"],
.tabs:has(#tab-2:checked) label[for="tab-2"],
.tabs:has(#tab-3:checked) label[for="tab-3"] {
  font-weight: 700;
  border-bottom: 2px solid currentColor;
}

The .tabs container knows which radio is checked. :has() exposes that state to any selector written against the container's subtree. No JavaScript touch required.

Pattern 4: Table Row Highlighting

Checkboxes in data tables are a common source of small event-driven JavaScript: check the box, highlight the row. With :has(), it is one CSS rule:

tr:has(input[type="checkbox"]:checked) {
  background-color: #eff6ff;
  outline: 1px solid #3b82f6;
  outline-offset: -1px;
}

The <tr> knows if its checkbox is checked. No event listener, no class on the row.

Specificity and Gotchas

:has() takes the specificity of the most specific selector in its argument list — the same rule as :is() and :not(). form:has(.error) has specificity (0,1,0). form:has(input.error) has specificity (0,2,0). The pseudo-class itself adds no specificity weight.

Three constraints worth knowing:

  • :has() cannot be nested inside another :has(). a:has(b:has(c)) is invalid syntax.
  • Keep inner selectors tightly constrained. A broad selector like :has(div) forces the browser to traverse large subtrees on every DOM mutation. form:has(.field:user-invalid) is faster to evaluate than form:has(:user-invalid) when the form is large and deeply nested.
  • :has() works with combinators. article:has(> h2) selects only articles with a direct <h2> child. section:has(~ .warning) selects a section that has a .warning sibling following it in the DOM.

The forgiving selector list behaviour is worth noting: an invalid selector inside :has() fails silently rather than invalidating the whole rule, the same way :is() handles unknown selectors. This makes progressive enhancement patterns straightforward.

Start Here

:has() is in every major browser and has been since December 2023. The MDN :has() reference covers the full selector grammar including forgiving lists, argument combinators, and the specificity rules. Ahmad Shadeed's interactive :has() guide is the most thorough visual reference for practical patterns. For the broader case for relational CSS, Smashing Magazine's deep dive covers dozens of patterns including quantity queries and sibling combinators.

The practical takeaway: wherever JavaScript is reading DOM state and toggling a class to expose it to CSS, :has() is the direct replacement. The state was always in the DOM. Now CSS can see it.

This is the fourth post in the native web series. Start with <dialog> for modals, then popovers, then native form validation, and now :has() for relational state. The browser has been doing this work.