The :has() pseudo-class matches an element when at least one of the selectors you pass to it matches something relative to that element. card:has(:checked) selects the card, not the checkbox. That makes it the parent selector CSS lacked for years, and because the argument can start with + or ~, it can also select an element based on what comes after it.

In practice this moves a lot of small UI logic out of JavaScript: highlighting a card when its checkbox is ticked, showing a form-level error banner, switching a grid layout when there are more than four items, showing an empty state when a list has no items, or switching a theme from a radio button.

Each demo below is a working component with its HTML and CSS to copy, a note on the key selector, and a fallback for browsers without :has(). The last sections cover specificity, what MDN says about performance, fallbacks and accessibility.

Style a card from the checkbox inside it

The classic parent-selector case. Each add-on card is a label that wraps a checkbox, and .addon:has(:checked) gives the whole card a border and a tinted background when its box is ticked. Before :has(), this needed either JavaScript or a hidden input placed before the card so a sibling selector could reach it.

The same idea moves the focus ring. .addon:has(:focus-visible) draws the outline around the whole card, so keyboard users see which option they are on, and .addon:has(:disabled) styles the sold-out card. The checkbox stays visible and native, so the control works the same with or without :has().

The form itself can react too: .picker:not(:has(.addon :checked)) swaps the summary line to a 'no extras selected' message when every box is cleared.

Open the demo in a new tab
Show the code
HTML
<form class="picker" action="#">
  <h1>Add extras to your workshop ticket</h1>
  <p class="intro">Front-end Accessibility Workshop, Riga – 14–15 November</p>
  <fieldset class="addons">
    <legend class="sr-only">Ticket extras</legend>
    <label class="addon">
      <input type="checkbox" name="extras" value="lunch" checked>
      <strong>Lunch on both days</strong>
      <span class="price">€24</span>
      <small>Vegetarian and vegan options, served on site.</small>
    </label>
    <label class="addon">
      <input type="checkbox" name="extras" value="recordings">
      <strong>Session recordings</strong>
      <span class="price">€39</span>
      <small>Captioned videos of all 9 talks, yours to keep.</small>
    </label>
    <label class="addon">
      <input type="checkbox" name="extras" value="workbook">
      <strong>Printed workbook</strong>
      <span class="price">€12</span>
      <small>120 pages of checklists and exercises.</small>
    </label>
    <label class="addon">
      <input type="checkbox" name="extras" value="dinner" disabled>
      <strong>Speakers' dinner</strong>
      <span class="price">Sold out</span>
      <small>All 30 seats are taken.</small>
    </label>
  </fieldset>
  <p class="summary">
    <span class="some">Extras are added to your total at checkout.</span>
    <span class="none">No extras selected – you can add them later.</span>
  </p>
</form>
CSS
.addons {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 12px;
  margin: 0;
  padding: 0;
  border: 0;
}
.addon {
  display: grid;
  grid-template-columns: auto 1fr auto;
  gap: 4px 12px;
  align-items: start;
  padding: 14px 16px;
  border: 2px solid #d5d8e2;
  border-radius: 12px;
  background: #fff;
  cursor: pointer;
}
.addon input { width: 20px; height: 20px; margin: 2px 0 0; accent-color: #4338ca; }
.addon strong { font-weight: 600; }
.addon .price { font-weight: 600; color: #3a3f4f; white-space: nowrap; }
.addon small { grid-column: 2 / -1; color: #4f5566; font-size: .82rem; }
.addon input:focus-visible { outline: 3px solid #4338ca; outline-offset: 2px; }

.summary { margin: 14px 0 0; font-size: .9rem; color: #3a3f4f; }
.summary .none { display: none; }
.sr-only {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

@supports selector(:has(*)) {
  /* The card reacts to the checkbox it contains */
  .addon:has(:checked) {
    border-color: #4338ca;
    background: #eef0ff;
    box-shadow: 0 6px 18px -10px rgb(67 56 202 / .6);
  }
  .addon:has(:checked) .price { color: #3730a3; }

  /* Move the focus ring from the tiny box to the whole card */
  .addon input:focus-visible { outline: none; }
  .addon:has(:focus-visible) { outline: 3px solid #4338ca; outline-offset: 3px; }

  .addon:has(:disabled) { cursor: not-allowed; background: #f1f2f6; border-style: dashed; }
  .addon:has(:disabled) strong { text-decoration: line-through; }

  /* The form knows whether anything is selected */
  .picker:not(:has(.addon :checked)) .summary .none { display: inline; }
  .picker:not(:has(.addon :checked)) .summary .some { display: none; }
}

Form-level validation states with form:has(:user-invalid)

:user-invalid matches a field that is invalid after the user has interacted with it, unlike :invalid, which matches an empty required field as soon as the page loads. Combined with :has(), one rule lets the whole field group react: .field:has(:user-invalid) colours the label, hides the hint and shows the error text.

Lift the anchor one level and the form reacts: .signup:has(:user-invalid) shows a banner above the submit button. The reverse state uses :not(): .signup:not(:has(:invalid)) shows a 'looks good' line once every field passes.

:user-invalid has its own support table (Chrome and Edge 119, Firefox 88, Safari 16.5), so this demo gates its rules on both features with @supports selector(:has(*)) and selector(:user-invalid). Without them, the browser's built-in validation still blocks the submit and shows its own message.

Open the demo in a new tab
Show the code
HTML
<form class="signup" action="#">
  <h1>Create your account</h1>
  <div class="row">
    <div class="field">
      <label for="email">Email</label>
      <input id="email" name="email" type="email" required autocomplete="email" aria-describedby="email-hint">
      <p class="hint" id="email-hint">We send the receipt here.</p>
      <p class="error">Enter an email like [email protected].</p>
    </div>
    <div class="field">
      <label for="user">Username</label>
      <input id="user" name="user" required pattern="[a-z0-9_]{3,16}" autocomplete="username" aria-describedby="user-hint">
      <p class="hint" id="user-hint">3–16 lowercase letters, digits or _.</p>
      <p class="error">Use 3–16 lowercase letters, digits or _.</p>
    </div>
  </div>
  <div class="field">
    <label for="pw">Password</label>
    <input id="pw" name="pw" type="password" required minlength="10" autocomplete="new-password" aria-describedby="pw-hint">
    <p class="hint" id="pw-hint">At least 10 characters.</p>
    <p class="error">Too short – use at least 10 characters.</p>
  </div>
  <p class="status problem">Some fields need attention. Check the messages in red.</p>
  <p class="status ready">Everything looks good.</p>
  <button type="submit">Create account</button>
</form>
CSS
.signup {
  padding: 20px;
  border: 1px solid #d5d8e2;
  border-radius: 14px;
  background: #fff;
}
.signup h1 { margin: 0 0 12px; font-size: 1.1rem; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
.field { display: grid; gap: 4px; margin-bottom: 10px; }
.field label { font-weight: 600; font-size: .88rem; }
.field input {
  font: inherit;
  padding: 8px 10px;
  border: 2px solid #8a90a2;
  border-radius: 8px;
}
.field input:focus-visible { outline: 3px solid #4338ca; outline-offset: 1px; }
.hint { margin: 0; font-size: .8rem; color: #4f5566; }
.error { display: none; margin: 0; font-size: .8rem; font-weight: 600; color: #b42318; }
.status { display: none; margin: 4px 0 12px; padding: 8px 12px; border-radius: 8px; font-size: .85rem; }
button {
  font: inherit; font-weight: 600;
  padding: 9px 18px;
  border: 0; border-radius: 999px;
  background: #4338ca; color: #fff; cursor: pointer;
}
button:focus-visible { outline: 3px solid #1f2430; outline-offset: 2px; }

/* Field level: works wherever :user-invalid works, no :has() needed */
@supports selector(:user-invalid) {
  .field input:user-invalid { border-color: #b42318; }
}

@supports selector(:has(*)) and selector(:user-invalid) {
  /* The whole field (label, hint, message) reacts to its input */
  .field:has(:user-invalid) label { color: #b42318; }
  .field:has(:user-invalid) .hint { display: none; }
  .field:has(:user-invalid) .error { display: block; }

  /* The form reacts to any field the user got wrong */
  .signup:has(:user-invalid) .status.problem {
    display: block;
    background: #fdecea; color: #8a1c12;
  }
  .signup:has(:user-invalid) button { background: #5b6170; }

  /* ...and to every field being valid */
  .signup:not(:has(:invalid)) .status.ready {
    display: block;
    background: #e6f4ea; color: #1d5b34;
  }
}

Quantity queries: change a grid by how many items it has

A container can count its children. .speakers:has(> :nth-child(5)) matches when there is a fifth child, which means five or more items. .speakers:has(> :only-child) matches exactly one item. Combine the two forms to get a range: :has(> :nth-child(2)):not(:has(> :nth-child(5))) means two to four items.

Use the demo buttons to add and remove speakers. One speaker gets a wide featured card, two to four sit in two columns, five or six use three columns, and seven or more switch to a compact four-column layout with the bios hidden. The buttons only add and remove list items; the layout is all CSS.

The child combinator in :has(> ...) keeps each check to direct children, which is also what MDN recommends for keeping :has() cheap. The fallback is a plain repeat(auto-fill, minmax(150px, 1fr)) grid, which already works at any count.

Open the demo in a new tab
Show the code
HTML
<ul class="speakers">
  <li class="speaker">
    <span class="avatar" aria-hidden="true">LB</span>
    <h2>Laura Bērziņa</h2>
    <p>Design systems lead</p>
    <p class="bio">How one token file can theme a dozen products.</p>
  </li>
  <li class="speaker">
    <span class="avatar" aria-hidden="true">TO</span>
    <h2>Tomás Ortega</h2>
    <p>Front-end lead</p>
    <p class="bio">Moving a large app to container queries.</p>
  </li>
  <li class="speaker">
    <span class="avatar" aria-hidden="true">AN</span>
    <h2>Aiko Nakamura</h2>
    <p>Accessibility specialist</p>
    <p class="bio">Testing forms with screen reader users every sprint.</p>
  </li>
  <li class="speaker">
    <span class="avatar" aria-hidden="true">MK</span>
    <h2>Mikkel Krogh</h2>
    <p>Performance engineer</p>
    <p class="bio">Cutting interaction latency in a busy dashboard.</p>
  </li>
</ul>
CSS
/* Fallback: a plain auto-fill grid */
.speakers {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.speaker {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 2px 10px;
  align-items: center;
  align-content: start;
  padding: 12px;
  border-radius: 12px;
  background: #fff;
  border: 1px solid #d5d8e2;
}
.avatar {
  grid-row: span 2;
  display: grid; place-items: center;
  width: 44px; height: 44px; border-radius: 50%;
  background: #e0e3ff; color: #3730a3; font-weight: 700;
}
.speaker h2 { margin: 0; font-size: .95rem; align-self: end; }
.speaker p { margin: 0; font-size: .8rem; color: #4f5566; align-self: start; }
.speaker .bio { grid-column: 1 / -1; margin-top: 6px; }

@media (max-width: 520px) {
  .speaker { grid-template-columns: 1fr; }
  .avatar { grid-row: auto; margin-bottom: 4px; }
}

@supports selector(:has(*)) {
  /* Exactly one item: a wide featured card */
  .speakers:has(> :only-child) { grid-template-columns: 1fr; }
  .speakers:has(> :only-child) .speaker { padding: 20px; }
  .speakers:has(> :only-child) .avatar { width: 72px; height: 72px; font-size: 1.4rem; }
  .speakers:has(> :only-child) .speaker h2 { font-size: 1.2rem; }

  /* Two to four items: two columns */
  .speakers:has(> :nth-child(2)):not(:has(> :nth-child(5))) {
    grid-template-columns: repeat(2, 1fr);
  }

  /* Five or more: three columns */
  .speakers:has(> :nth-child(5)) { grid-template-columns: repeat(3, 1fr); }

  /* Seven or more: four compact columns, bios hidden */
  .speakers:has(> :nth-child(7)) { grid-template-columns: repeat(4, 1fr); gap: 8px; }
  .speakers:has(> :nth-child(7)) .bio { display: none; }
  .speakers:has(> :nth-child(7)) .speaker { grid-template-columns: 1fr; padding: 10px; }
  .speakers:has(> :nth-child(7)) .avatar { grid-row: auto; width: 34px; height: 34px; font-size: .8rem; margin-bottom: 4px; }
  .speakers:has(> :nth-child(7)) .speaker h2 { font-size: .85rem; }

  @media (max-width: 520px) {
    .speakers:has(> :nth-child(2)) { grid-template-columns: repeat(2, 1fr); }
  }
}

Hover a photo and the others fade back. The rule reads: in a gallery that has a hovered or focused tile, style every tile that is not hovered or focused. In CSS that is .gallery:has(.tile:hover, .tile:focus-visible) .tile:not(:hover, :focus-visible).

Including :focus-visible matters: the effect then follows keyboard focus too, not only the mouse. Only the artwork layer fades. The captions sit on their own dark bar so they stay readable, and the transition only runs when the user has not asked for reduced motion.

Without :has(), every tile stays at full strength, which is a reasonable default for a gallery.

Open the demo in a new tab
Show the code
HTML
<ul class="gallery">
  <li><a class="tile" href="#jurmala" style="--art: linear-gradient(160deg, #f6c37a, #d9734e 55%, #5a3a64)"><span>Jūrmala at sunset</span></a></li>
  <li><a class="tile" href="#kolka" style="--art: linear-gradient(200deg, #9fd3e6, #3f7fa6 60%, #20364f)"><span>Cape Kolka</span></a></li>
  <li><a class="tile" href="#pape" style="--art: linear-gradient(140deg, #d7e3c2, #87a86b 55%, #3c5434)"><span>Pape dunes</span></a></li>
  <li><a class="tile" href="#liepaja" style="--art: linear-gradient(180deg, #c8c3d9, #7c7aa8 55%, #353452)"><span>Liepāja pier, fog</span></a></li>
  <li><a class="tile" href="#ventspils" style="--art: linear-gradient(120deg, #f2e0b5, #c9a35c 50%, #6d4f27)"><span>Ventspils beach</span></a></li>
  <li><a class="tile" href="#saulkrasti" style="--art: linear-gradient(220deg, #f4b8b0, #c4677a 55%, #4b2a44)"><span>Saulkrasti White Dune</span></a></li>
</ul>
CSS
.gallery {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 10px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tile {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  aspect-ratio: 4 / 3;
  border-radius: 12px;
  overflow: hidden;
  color: #fff;
  text-decoration: none;
}
/* The artwork sits on its own layer so the caption never fades */
.tile::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--art);
}
.tile span {
  padding: 6px 10px;
  background: rgb(17 19 28 / .82);
  font-size: .82rem;
  font-weight: 600;
}
.tile:focus-visible { outline: 3px solid #4338ca; outline-offset: 3px; }

@supports selector(:has(*)) {
  /* When any tile is hovered or focused, every other tile steps back */
  .gallery:has(.tile:hover, .tile:focus-visible)
    .tile:not(:hover, :focus-visible)::before {
    opacity: .3;
    filter: grayscale(1);
  }
  @media (prefers-reduced-motion: no-preference) {
    .tile::before { transition: opacity .25s ease, filter .25s ease; }
  }
}

@media (max-width: 460px) {
  .gallery { grid-template-columns: repeat(2, 1fr); }
}

Empty states and all-done states without JavaScript

.todo:not(:has(.tasks li)) matches a to-do card whose list has no items, and shows the empty message. Unlike :empty, it does not care about whitespace or comments left inside the list, which stop :empty from matching in current browsers.

Chaining gives more states. .todo:has(.tasks li):not(:has(.tasks input:not(:checked))) reads as: there are tasks, and none of them is unchecked, so everything is done. .todo:has(.tasks :checked) .clear only shows the 'Clear done' button when there is something to clear.

Note the order in :not(:has(...)) versus :has(:not(...)). The first means 'contains no X'; the second means 'contains something that is not X'. The fallback uses .tasks:empty + .empty, and the demo script clears leftover whitespace when the last item is removed so that it matches.

Open the demo in a new tab
Show the code
HTML
<section class="todo" aria-labelledby="todo-title">
  <div class="todo-head">
    <h1 id="todo-title">Before Friday's release</h1>
    <button type="button" class="btn ghost clear">Clear done</button>
  </div>
  <ul class="tasks">
    <li><label><input type="checkbox" checked><span>Update the changelog</span></label><button type="button" class="remove">Remove</button></li>
    <li><label><input type="checkbox"><span>Test checkout in Safari</span></label><button type="button" class="remove">Remove</button></li>
    <li><label><input type="checkbox"><span>Email the beta testers</span></label><button type="button" class="remove">Remove</button></li>
  </ul>
  <p class="empty">No tasks left on this list. Add one below.</p>
  <p class="done-all">All tasks are done. Time to ship.</p>
</section>
CSS
.todo {
  padding: 18px 20px;
  border: 1px solid #d5d8e2;
  border-radius: 14px;
  background: #fff;
}
.todo-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.todo h1 { margin: 0; font-size: 1.1rem; }
.tasks { margin: 10px 0 0; padding: 0; list-style: none; }
.tasks li {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid #e6e8ef;
}
.tasks label { flex: 1; display: flex; align-items: center; gap: 10px; cursor: pointer; }
.tasks input { width: 18px; height: 18px; accent-color: #4338ca; }
.tasks input:focus-visible { outline: 3px solid #4338ca; outline-offset: 2px; }
.remove {
  border: 0; background: none; cursor: pointer;
  font: inherit; font-size: .8rem; color: #4f5566; text-decoration: underline;
}
/* Works without :has(): the checkbox is right before the text */
.tasks input:checked + span { text-decoration: line-through; color: #4f5566; }

.empty, .done-all { display: none; margin: 12px 0 0; padding: 14px; border-radius: 10px; text-align: center; }
.empty { background: #f3f4f8; color: #3a3f4f; }
.done-all { background: #e6f4ea; color: #1d5b34; font-weight: 600; }
/* Fallback empty state: only matches a list with no children at all */
.tasks:empty + .empty { display: block; }

@supports selector(:has(*)) {
  /* Empty: the list has no items */
  .todo:not(:has(.tasks li)) .empty { display: block; }
  .todo:not(:has(.tasks li)) .tasks { display: none; }

  /* All done: there are items, and none is unchecked */
  .todo:has(.tasks li):not(:has(.tasks input:not(:checked))) .done-all { display: block; }

  /* Only offer "Clear done" when something is done */
  .clear { display: none; }
  .todo:has(.tasks :checked) .clear { display: inline-block; }
}

A theme switch with :root:has()

Three radio buttons, no script. :root:has(#theme-dark:checked) redefines the colour custom properties and sets color-scheme: dark, so form controls and scrollbars follow. 'System' is the default and uses a prefers-color-scheme media query.

An explicit choice wins over the media query because of specificity: :root:has(#theme-dark:checked) counts as an ID plus two pseudo-classes, more than :root alone. See the specificity section below.

CSS cannot remember the choice across page loads. To persist it, save the checked value with a few lines of JavaScript or render the checked attribute on the server. Without :has(), the page simply follows the system setting.

Open the demo in a new tab
Show the code
HTML
<article class="reader">
  <fieldset class="theme">
    <legend>Theme</legend>
    <label><input type="radio" name="theme" id="theme-system" checked> System</label>
    <label><input type="radio" name="theme" id="theme-light"> Light</label>
    <label><input type="radio" name="theme" id="theme-dark"> Dark</label>
  </fieldset>
  <h1>Why the lighthouse keeper kept two logbooks</h1>
  <p class="meta">Long read · 9 minutes</p>
  <p>One book recorded the weather every four hours: wind, visibility, the state of the sea. The other recorded ships, each one by name when the keeper could read it through the glass.</p>
  <p>Historians now use both to rebuild the storms of the 1890s, <a href="#sources">hour by hour</a>.</p>
</article>
CSS
:root {
  color-scheme: light;
  --bg: #f6f4ef;
  --surface: #ffffff;
  --text: #22201c;
  --muted: #57534a;
  --accent: #9a3412;
  --border: #d9d4c7;
}
/* "System" follows the OS setting */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --bg: #16171c;
    --surface: #1f2128;
    --text: #ecebe8;
    --muted: #b4b2ac;
    --accent: #fdba74;
    --border: #3a3d47;
  }
}

@supports selector(:has(*)) {
  /* An explicit choice beats the OS setting */
  :root:has(#theme-light:checked) {
    color-scheme: light;
    --bg: #f6f4ef; --surface: #ffffff; --text: #22201c;
    --muted: #57534a; --accent: #9a3412; --border: #d9d4c7;
  }
  :root:has(#theme-dark:checked) {
    color-scheme: dark;
    --bg: #16171c; --surface: #1f2128; --text: #ecebe8;
    --muted: #b4b2ac; --accent: #fdba74; --border: #3a3d47;
  }
  .theme label:has(:checked) { border-color: var(--accent); }
}

body { background: var(--bg); color: var(--text); }
.reader {
  padding: 20px 22px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--surface);
}
.theme {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 0 0 16px; padding: 0; border: 0;
  font: .85rem/1.4 system-ui, sans-serif;
}
.theme legend { float: left; margin-right: 8px; padding: 6px 0; font-weight: 600; }
.theme label {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 12px;
  border: 1px solid var(--border); border-radius: 999px;
  cursor: pointer;
}
.theme input { accent-color: var(--accent); margin: 0; }
.theme input:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.reader h1 { margin: 0 0 4px; font-size: 1.35rem; line-height: 1.25; }
.reader .meta { margin: 0 0 10px; color: var(--muted); font: .82rem/1.4 system-ui, sans-serif; }
.reader p { margin: 0 0 10px; }
.reader a { color: var(--accent); }
.reader a:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }

Style a field group and reveal fields from input state

A wrapper can follow the control inside it. .field:has(:focus-visible) highlights the whole group, label included, while its input shows a focus ring. .field:has(:disabled) dims the label and switches the border to dashed, so the read-only country field looks different from the editable ones.

Optional fields can stay hidden until they are needed. .delivery:has(#gift:checked) .reveal.gift shows the gift message box when its checkbox is ticked. Because the hiding rule is inside the @supports block, older browsers show every field instead of hiding fields they could never reveal.

Hidden with display: none, the extra fields are also out of the tab order and the accessibility tree until they appear, which is what you want for optional parts of a form.

Open the demo in a new tab
Show the code
HTML
<fieldset class="delivery">
  <legend>Delivery details</legend>
  <div class="grid">
    <div class="field">
      <label for="name">Full name</label>
      <input id="name" autocomplete="name" value="Ilze Kalniņa">
    </div>
    <div class="field">
      <label for="country">Country</label>
      <input id="country" value="Latvia" disabled aria-describedby="country-hint">
      <small id="country-hint">We ship within Latvia only.</small>
    </div>
  </div>

  <label class="check"><input type="checkbox" id="gift"> This order is a gift</label>
  <div class="field reveal gift">
    <label for="gift-msg">Gift message</label>
    <textarea id="gift-msg" rows="2">Happy birthday, see you in Cēsis.</textarea>
  </div>

  <label class="check"><input type="checkbox" id="leave"> The courier may leave the parcel</label>
  <div class="field reveal leave">
    <label for="leave-where">Where?</label>
    <select id="leave-where">
      <option>With a neighbour</option>
      <option>At the front door</option>
      <option>In the building's parcel room</option>
    </select>
  </div>
</fieldset>
CSS
.delivery {
  margin: 0;
  padding: 18px 20px 8px;
  border: 1px solid #d5d8e2;
  border-radius: 14px;
  background: #fff;
}
.delivery legend { padding: 0 6px; font-weight: 700; font-size: 1.05rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0 12px; }
.field {
  display: grid; gap: 3px;
  margin-bottom: 12px;
  padding: 6px 10px 8px;
  border-left: 3px solid transparent;
  border-radius: 0 8px 8px 0;
}
.field label { font-size: .85rem; font-weight: 600; color: #3a3f4f; }
.field input, .field textarea, .field select {
  font: inherit; padding: 7px 10px;
  border: 2px solid #8a90a2; border-radius: 8px;
  background: #fff; color: inherit;
}
.field textarea { resize: vertical; min-height: 0; }
.field :focus-visible { outline: 3px solid #4338ca; outline-offset: 1px; }
.check { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; padding-left: 13px; font-size: .9rem; cursor: pointer; }
.check input { width: 18px; height: 18px; margin: 0; accent-color: #4338ca; }
.check input:focus-visible { outline: 3px solid #4338ca; outline-offset: 2px; }
.reveal { margin: -4px 0 10px 26px; }

@supports selector(:has(*)) {
  /* The whole group lights up while its control shows a focus ring */
  .field:has(:focus-visible) { border-left-color: #4338ca; background: #f3f4ff; }
  .field:has(:focus-visible) label { color: #3730a3; }

  /* Disabled input: the label follows */
  .field:has(:disabled) label { color: #5b6170; }
  .field:has(:disabled) input { border-style: dashed; background: #f1f2f6; }

  /* Extra fields appear only when their checkbox is ticked */
  .reveal { display: none; }
  .delivery:has(#gift:checked) .reveal.gift,
  .delivery:has(#leave:checked) .reveal.leave { display: grid; }
}

Previous siblings with :has(+) and :has(~)

CSS combinators only look forward: A + B and A ~ B select B. Putting the combinator inside :has() flips that. .step:has(~ [aria-current="step"]) selects every step that has the current step somewhere after it, which is exactly the list of completed steps in the order tracker.

Combined with :not(), you get the rest: .step:not([aria-current="step"]):not(:has(~ [aria-current="step"])) is every upcoming step. The tracker keys off aria-current, the attribute that also tells screen readers which step is current, so styling and semantics come from the same source.

The dock shows the one-step version. li:hover + li reaches the next icon without :has(), and li:has(+ li:hover) reaches the previous one, so both neighbours grow. The demo buttons only move aria-current along the list.

Open the demo in a new tab
Show the code
HTML
<h1>Order #48213</h1>
<p class="meta">Wool blanket, grey · arriving Thursday</p>
<ol class="tracker" aria-label="Order progress">
  <li class="step">Ordered</li>
  <li class="step">Packed</li>
  <li class="step" aria-current="step">Shipped</li>
  <li class="step">Out for delivery</li>
  <li class="step">Delivered</li>
</ol>

<h2>Quick links</h2>
<ul class="dock">
  <li><a href="#home" aria-label="Home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11 12 4l9 7v9h-6v-6H9v6H3z"/></svg></a></li>
  <li><a href="#search" aria-label="Search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/></svg></a></li>
  <li><a href="#orders" aria-label="Orders"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 7h16l-1.5 12h-13z"/><path d="M9 7a3 3 0 0 1 6 0"/></svg></a></li>
  <li><a href="#messages" aria-label="Messages"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 5h16v11H9l-5 4z"/></svg></a></li>
  <li><a href="#settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9 7 7M17 17l2.1 2.1M4.9 19.1 7 17M17 7l2.1-2.1"/></svg></a></li>
</ul>
CSS
/* ---------- Order tracker ---------- */
.tracker {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  margin: 0; padding: 0; list-style: none;
  counter-reset: step;
}
.step {
  position: relative;
  display: grid; justify-items: center; gap: 6px;
  text-align: center; font-size: .8rem; color: #3a3f4f;
  counter-increment: step;
}
.step::before {
  content: counter(step);
  position: relative; z-index: 1;
  display: grid; place-items: center;
  width: 30px; height: 30px; border-radius: 50%;
  border: 2px solid #8a90a2; background: #fff;
  font-weight: 700;
}
/* connector line to the next step */
.step:not(:last-child)::after {
  content: "";
  position: absolute; top: 14px; left: 50%; width: 100%; height: 3px;
  background: #d5d8e2;
}
/* Works everywhere: the current step */
.step[aria-current="step"] { font-weight: 700; color: #1f2430; }
.step[aria-current="step"]::before { border-color: #4338ca; color: #3730a3; box-shadow: 0 0 0 4px #e0e3ff; }

@supports selector(:has(*)) {
  /* Every step that has the current step somewhere after it is done */
  .step:has(~ [aria-current="step"])::before {
    content: "✓";
    border-color: #1d7a45; background: #1d7a45; color: #fff;
  }
  .step:has(~ [aria-current="step"])::after { background: #1d7a45; }

  /* Upcoming: not current, and no current step after it */
  .step:not([aria-current="step"]):not(:has(~ [aria-current="step"])) { color: #4f5566; }
}

/* ---------- Dock: grow the hovered item and both neighbours ---------- */
.dock {
  display: flex; justify-content: center; align-items: flex-end; gap: 12px;
  margin: 0; padding: 22px 12px 10px;
  list-style: none;
  border-radius: 18px; background: #1f2430;
}
.dock a {
  display: grid; place-items: center;
  width: 44px; height: 44px; border-radius: 12px;
  background: #3a3f4f; color: #fff;
  transform-origin: bottom center;
}
.dock svg { width: 22px; height: 22px; }
.dock a:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 3px; }
.dock li:is(:hover, :focus-within) a { transform: scale(1.25); background: #4338ca; }
/* next sibling: possible without :has() */
.dock li:is(:hover, :focus-within) + li a { transform: scale(1.12); }

@supports selector(:has(*)) {
  /* previous sibling: needs :has() */
  .dock li:has(+ li:is(:hover, :focus-within)) a { transform: scale(1.12); }
}
@media (prefers-reduced-motion: no-preference) {
  .dock a { transition: transform .18s ease, background-color .18s ease; }
}

Specificity of :has()

MDN states that :has() takes the specificity of the most specific selector in its argument, the same as :is() and :not(). So .card:has(#promo) counts as one ID and one class, while .card:has(p) counts as one class and one element.

That cuts both ways. In the theme demo, :root:has(#theme-dark:checked) easily beats the plain :root rules, which is what we want. But a :has() with an ID inside can be hard to override later. To keep a rule at zero specificity, wrap it in :where(), for example :where(.card:has(:checked)).

Two more rules from the Selectors Level 4 spec: :has() cannot be nested inside another :has(), and pseudo-elements are not valid inside it. Its argument is also no longer a forgiving list, so one invalid selector in :has(a, b) makes the whole rule invalid.

Performance notes

MDN notes that :has() may need to traverse a whole subtree to find a match, and its guidance is about keeping that subtree small. First, avoid anchoring on elements with many descendants, such as body, :root or the universal selector. Second, use combinators like > or + inside the argument to limit how far the browser has to look, for example .ancestor:has(> .foo) instead of .ancestor:has(.foo).

Most demos here follow that guidance: the quantity query uses :has(> :nth-child(5)) and the anchors are small components. The theme switch is the exception, because a page-wide theme has to be anchored on :root. It is one rule, and its argument is an ID.

We have not measured these demos for performance, and we don't quote numbers here. If a page feels slow, the Performance panel in browser developer tools shows time spent on style recalculation.

Fallbacks and progressive enhancement

A browser that does not understand :has() throws away the whole rule, including any other selectors in the same comma-separated list. Keep :has() rules in their own blocks, or inside :is() or :where(), which MDN notes are forgiving.

For anything that changes layout or visibility, use @supports selector(:has(*)). Write the fallback first: show every optional field, use a plain auto-fill grid, keep native checkboxes visible. Then hide or rearrange things inside the @supports block. The opposite check, @supports not selector(:has(*)), is how each demo shows its 'showing the fallback' note.

Some fallbacks need no :has() at all. The to-do demo strikes through finished tasks with input:checked + span, and the dock grows the next icon with li:hover + li. Use the older selector where it can do the job, and add :has() for the extra states.

Accessibility

:has() only changes how things look. It does not change roles, names or states, so start from correct HTML: real labels, fieldsets with legends, native inputs, and aria-current for the current step, as in the order tracker.

Error messages shown only with CSS are not announced by screen readers when they appear. Keep native constraint validation on, link persistent hints with aria-describedby, and add aria-invalid and a live region with JavaScript if you need announcements.

If you move a focus indicator from an input to its card with :has(:focus-visible), remove the input's own outline only inside the same @supports block, so no browser is left without one. Also remember that text added with ::before or ::after, such as the check marks in the tracker, is read by assistive technology.

Frequently asked questions

Is :has() the CSS parent selector?

Yes, in practice. div:has(> img) selects a div that has an img as a direct child, which is the parent selector people asked for. It is more general than that: the argument can look at any descendant, or at following siblings with + and ~.

Which browsers support CSS :has()?

Chrome and Edge 105, Safari 15.4 and Firefox 121. It became Baseline in December 2023 when Firefox shipped it, and is now listed as Baseline widely available. Older browsers ignore any rule that contains :has().

How do I detect :has() support in CSS?

Use @supports selector(:has(*)) { ... } for the enhanced rules and @supports not selector(:has(*)) { ... } for fallback-only rules. In JavaScript, CSS.supports('selector(:has(*))') returns the same answer.

Can I select a previous sibling with :has()?

Yes. a:has(+ b) selects an a that is immediately followed by b, and a:has(~ b) selects an a that has a b anywhere after it among its siblings. The order tracker demo uses the second form to mark every completed step.

Can :has() be nested or used with pseudo-elements?

No to both. The Selectors Level 4 spec says :has() is not valid inside another :has(), and pseudo-elements such as ::before are not valid inside its argument. You can still put a pseudo-element after it, as in .step:has(~ .current)::before.

More from the Modern CSS lab