Codeskill

Learn to code, step by step

Modern selectors – :is(), :where(), and :has()

Modern CSS selectors you will actually use: :is(), :where(), and :has(). The goal is shorter, clearer selectors and styling based on document structure without sprinkling extra classes on every parent.

:is() – grouping without repetition

:is() accepts a selector list and matches if any branch matches. It collapses long comma-separated lists:

/* Before */
article h1,
article h2,
article h3 {
  line-height: 1.2;
}

/* After */
article :is(h1, h2, h3) {
  line-height: 1.2;
}

Specificity is the highest-specificity argument inside :is(). So :is(.nav, #menu) a counts as #menu a specificity (ID wins).

:where() – grouping with zero specificity

:where() works like :is() but contributes zero specificity. Use it for base styles you expect to override:

:where(h1, h2, h3, h4) {
  font-weight: 600;
  line-height: 1.25;
}

.card :where(h2) {
  font-size: 1.25rem;
  margin-block: 0 0.5rem;
}

A single utility class like .text-light overrides these without a fight. That is the point.

:has() – the parent selector

:has() selects an element if it contains something matching the argument. Finally, you can style a card because it contains an image, or a form row because an input is invalid:

/* Card with a thumbnail gets a horizontal layout */
.card:has(.card__media) {
  display: grid;
  grid-template-columns: 120px 1fr;
  gap: 1rem;
}

/* Highlight a nav item that contains the current page link */
.nav li:has([aria-current="page"]) {
  font-weight: 600;
}

/* Style the label when its input is required */
label:has(+ input[required])::after {
  content: " (required)";
  font-weight: normal;
  color: var(--color-muted, #6b7280);
}

:has() is powerful and easy to overuse. If a condition is stable and semantic, a class on the parent is still clearer for the next reader. Reach for :has() when the DOM shape already expresses the condition and adding a class would be boilerplate.

Form patterns with :has()

Invalid and valid states often live on inputs. With :has(), the surrounding field wrapper can react:

<div class="field">
  <label for="email">Email</label>
  <input id="email" type="email" required>
</div>
.field:has(input:user-invalid) {
  border-inline-start: 3px solid #dc2626;
  padding-inline-start: 0.75rem;
}

.field:has(input:user-valid:not(:placeholder-shown)) {
  border-inline-start: 3px solid #16a34a;
}

:user-invalid and :user-valid fire after interaction, which avoids screaming red borders on every field before the user has typed. We cover form states in more detail later in these tutorials.

:not() got smarter

:not() now accepts complex selectors, not just simple ones:

/* Style all direct children except the last two */
.grid > :not(:nth-last-child(-n+2)) {
  margin-block-end: 1rem;
}

/* Links that are not inside a heading */
a:not(:is(h1, h2, h3) a) {
  text-decoration: underline;
}

Combining selectors

Real selectors stack these tools:

/* Sidebar section that contains a list but no heading */
aside :where(section):has(ul):not(:has(:is(h2, h3))) {
  padding: 1rem;
  background: #f9fafb;
}

If you write something this dense, add a comment explaining the intent. Future you will thank present you.

Performance and support

:has() can be expensive on large documents if the browser must re-evaluate wide subtrees on every change. For typical UI components it is fine. Test on target browsers; support is solid in current Chrome, Firefox, and Safari.

When support is uncertain, treat :has() as progressive enhancement: the page works without it, it just looks plainer.

The next tutorial introduces container queries – components that respond to their parent’s size, not the viewport.

PreviousCustom properties in real layouts