ESC

Type to search the knowledge base.

:is :where :has

Selector engines that cut repetition — :is and :where for lists, specificity traps, and :has for parent/sibling state without JS.

intermediate3 min read
  • css
  • selectors
  • has

Three pseudo-classes reshaped how we write selectors: :is() and :where() group alternatives; :has() is the long-requested “parent selector” (more precisely, a relational selector). Together they replace whole families of duplicated rules — and introduce specificity and performance nuances you should know cold.

Docs: MDN :is(), :where(), :has().

:is() — grouping with real specificity

/* Instead of three rules */
:is(h1, h2, h3).title {
  line-height: 1.2;
}

.card :is(a, button) {
  font-weight: 600;
}

Specificity of :is() is the specificity of its most specific argument.

:is(.chip, #featured) {
  /* counts as an ID when #featured is in the list */
  outline: 2px solid red;
}

Forgiving parsing: invalid selectors inside :is() are dropped, not the whole list (unlike older selector lists in some contexts).

:where() — grouping with zero specificity

:where(h1, h2, h3, h4, h5, h6) {
  margin-block: 0 0.5em;
  font-weight: 650;
}

.prose h2 {
  margin-block-start: 1.5em; /* easy override */
}

Design-system base styles love :where() so a single class can override without wars.

:has() — style based on descendants/siblings

/* Card with an image gets different padding */
.card:has(img) {
  padding: 0;
}

.card:has(img) .card__body {
  padding: 1rem;
}

/* Form field invalid state on the group */
.field:has(:invalid:not(:placeholder-shown)) {
  border-color: var(--danger);
}

/* Previous-sibling-ish patterns */
label:has(+ input:focus) {
  color: var(--brand);
}
/* Navigation current section highlighting parents */
.nav__item:has(.nav__link[aria-current="page"]) {
  background: var(--brand-soft);
}

:has() is powerful enough to replace small amounts of class-toggling JS for pure presentational state derived from the DOM.

Combining them

.table-row:has(:is(input:checked, [aria-selected="true"])) {
  background: var(--selected);
}

:where(.btn, .chip):has(svg) {
  display: inline-flex;
  gap: 0.35rem;
  align-items: center;
}

Performance awareness

:has() can be costlier than simple class selectors because it may need to check relationships as the DOM mutates. Prefer:

  • Shallow relationships (.card:has(> img) when possible)
  • Avoid extremely broad .page:has(…) on huge trees for frequent updates

In practice for typical UI it’s fine; measure if you attach it to rapidly updating lists.

Interview out-loud

“:is() groups selectors and takes the highest specificity of its arguments. :where() groups with zero specificity for low-priority defaults. :has() styles an element based on matching relatives — parent selectors for practical UI. I use :where for base, :is for DRY variants, and :has for structural state without extra classes.”

Footguns

  1. ID inside :is() silently raising specificity.
  2. Overusing :has instead of a clear state class for complex app state.
  3. Expecting :has in very old browsers without fallbacks.
  4. Circular thinking — styles that constantly reflow under :has conditions.
  5. Forgetting :has relative selectors still need valid combinators.

Replacing old preprocessor lists

/* Old hand-expanded */
/* .btn:hover, .chip:hover, .link:hover { } */

:is(.btn, .chip, .link):hover {
  color: var(--brand);
}

:where(.prose :is(h1, h2, h3, h4)) {
  line-height: 1.25;
  text-wrap: balance;
}

:has for form UX without JS classes

.password-field:has(input:focus) .strength {
  opacity: 1;
}
.form:has(:invalid:not(:placeholder-shown)) .form-status {
  color: var(--danger);
}

Keep server validation authoritative. CSS :has only styles; it does not secure anything. For app state that is not in the DOM (async “saving…”), a class from JS remains clearer than inventing hidden DOM flags.

Further reading

Related guides