Home / Articles / Modern CSS /

modern-csshas

:has() from scratch — the parent selector CSS always needed

For decades, CSS could only style downward. :has() flips that — select any element based on what it contains, and build interactive UI without a line of JavaScript.

What :has() actually does

The :has() pseudo-class selects an element if it contains at least one descendant matching the argument selector. Think of it as "select the parent when the child matches." Before :has(), the only way to style a parent based on its children was JavaScript.

/* Select any .card that contains an image */
.card:has(img) {
  grid-template-rows: auto 1fr;
}

/* Select any .card that does NOT contain an image */
.card:not(:has(img)) {
  padding: 2rem;
}

The first rule applies only when a .card has an <img> descendant. The second uses :not(:has()) to target cards without images. Both were impossible in pure CSS before.

Basic syntax and matching

:has() takes a relative selector list as its argument. The selectors inside are evaluated relative to the element :has() is attached to, not relative to the document root.

/* Direct child only — use > */
.nav:has(> .dropdown) {
  position: relative;
}

/* Any descendant (default) */
.form:has(input:focus) {
  border-color: oklch(0.72 0.19 265);
}

/* Multiple conditions — any match wins */
.card:has(video, audio) {
  background: oklch(0.19 0.02 260);
}

Using the child combinator (>) inside :has() restricts the match to direct children. Without it, any descendant at any depth will match.

Combining :has() with pseudo-classes

The real power of :has() shows up when you combine it with state pseudo-classes like :focus, :checked, :hover, and :valid. This lets you react to user interaction at the parent level.

/* Highlight a form group when its input is focused */
.form-group:has(input:focus) {
  background: oklch(0.19 0.03 265);
  border-left: 3px solid oklch(0.72 0.19 265);
  padding-left: 1rem;
}

/* Style a label when its sibling checkbox is checked */
.option:has(input[type="checkbox"]:checked) label {
  color: oklch(0.72 0.19 265);
  font-weight: 600;
}

/* Dim the entire form when a required field is invalid */
form:has(:required:invalid:not(:placeholder-shown)) {
  opacity: 0.85;
}

Each of these patterns previously required an event listener and a class toggle. With :has(), the CSS engine handles it natively.

Selecting beyond parent: document-level :has()

You are not limited to component-level selectors. :has() on body or html lets you change the entire page based on one element's state — like a global CSS variable toggle.

/* Blur the page when a dialog is open */
body:has(dialog[open]) main {
  filter: blur(4px);
  pointer-events: none;
}

/* Switch layout when a sidebar toggle is checked */
body:has(#sidebar-toggle:checked) .layout {
  grid-template-columns: 1fr;
}

/* Dark mode toggle via checkbox */
html:has(#dark-mode:checked) {
  --bg: oklch(0.13 0.02 260);
  --text: oklch(0.93 0.01 260);
}

Use document-level :has() sparingly. Browsers have optimized it well, but a broad selector like body:has(*) forces the engine to check every element in the tree.

Negation and compound conditions

Combine :has() with :not(), :is(), and :where() for precise compound conditions that replace multi-step JavaScript logic.

/* Card with an image but no heading */
.card:has(img):not(:has(h2, h3)) {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
}

/* Fieldset valid only when ALL required inputs are valid */
fieldset:not(:has(:required:invalid)) .status-icon::before {
  content: "\2713";
  color: oklch(0.7 0.18 145);
}

/* Container with either a video or an iframe */
.media:has(:is(video, iframe)) {
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: 0.75rem;
}

The :not(:has()) pattern is especially useful — it lets you provide fallback styles for when content is absent, replacing the need for "empty state" class names.

Interactive example: accordion without JS

Here is a complete accordion built with :has() and the native <details> element. No JavaScript, no ARIA toggling — the browser handles accessibility.

.accordion-group:has(details[open]) details:not([open]) {
  opacity: 0.5;
}

details[open] summary {
  color: oklch(0.72 0.19 265);
  border-bottom: 2px solid oklch(0.72 0.19 265);
}

.accordion-group:has(details[open]) {
  gap: 0.25rem;
}

.accordion-group:not(:has(details[open])) {
  gap: 0.75rem;
}

When one panel opens, all other panels fade to 50% opacity, drawing focus to the active content. The gap adjusts automatically. This entire interaction is declarative.

Browser support and fallbacks

:has() shipped in Chrome 105, Safari 15.4, and Firefox 121. As of 2026, global support exceeds 95%. For the rare older browser, wrap :has() rules in @supports selector(:has(*)) and provide a baseline fallback.

/* Fallback: always show the error hint */
.field .error {
  display: block;
}

/* Enhancement: hide it until the input is invalid */
@supports selector(:has(*)) {
  .field .error {
    display: none;
  }
  .field:has(input:invalid:not(:placeholder-shown)) .error {
    display: block;
    color: oklch(0.65 0.2 25);
  }
}

This progressive enhancement approach means users on older browsers still get a functional — if less polished — experience.