EduFlow
Modern CSS Mastery
0%

Section 1 · The Cascade, for Real

Native Nesting and :has()

18 min3 min readCode playground

Lesson at a glance

Write component styles with native CSS nesting, and use :has() to style an element based on what it contains, including a schedule filter that needs no JavaScript.

  1. 1Native nesting
  2. 2:has(): style an element by what it contains
  3. 3A filter bar with no JavaScript
  4. 4Three more selectors that replace JavaScript
  5. 5Your turn

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Two things bother everyone who maintains Waypoint’s card CSS. The card’s rules are scattered: the base rule here, the hover state twenty lines down, the dark-mode override in another file. And the design team keeps asking for styles that depend on what’s inside the card (“if a session is sold out, dim it”), which used to mean a JavaScript class toggle.

Native nesting fixes the first, and :has() fixes the second.

Native nesting

You can now nest rules inside rules, in plain CSS, with no build step:

session-card.css
.session-card {
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 12px;

  h2 {
    margin-block: 0 0.5rem;
    font-size: 1.125rem;
  }

  &:hover {
    border-color: #a78bfa;
  }

  .featured & {
    border-width: 2px;
  }

  @media (width >= 48rem) {
    padding: 1.5rem;
  }
}

& stands for the parent selector. &:hover means “this card when hovered”, and .featured & means “this card inside .featured”. A nested selector without &, like h2, is treated as a descendant: .session-card h2. Nested @media and @container rules apply to the parent selector, so the padding change lives next to the padding.

Nested selectors can start with an element name (h2, not & h2). Early implementations required a symbol first; every current browser now supports the relaxed syntax, and nesting has been Baseline since December 2023 (widely available since mid-2026).

Two more rules keep nesting healthy. Under the hood, & behaves like :is() wrapped around the parent list, so #keynote, .session-card { h2 { … } } gives every nested h2 rule ID-level specificity. And stop at two levels deep: a selector you can’t read in one glance is one you can’t override in one glance.

:has(): style an element by what it contains

:has() takes a relative selector and matches the element if that selector finds something:

CSS
/* A card that contains a speaker photo gets a two-column layout */
.session-card:has(img) {
  display: grid;
  grid-template-columns: 64px 1fr;
  gap: 1rem;
}

/* Dim sold-out sessions */
.session-card:has(.badge-full) {
  opacity: 0.6;
}

/* A label whose following input is invalid after the user typed */
label:has(+ input:user-invalid) {
  color: #b91c1c;
}

The last example shows that :has() is more than a “parent selector”: + input looks at the next sibling, so an earlier element can react to a later one. :user-invalid is the polite cousin of :invalid; it only matches after the user has interacted with the field, so an empty form doesn’t load covered in red.

:has() has two limits worth knowing before you hit them. You can’t nest a :has() inside another :has(), and you can’t put pseudo-elements such as ::before inside it. Its specificity follows the :is() rule from earlier in this section: the most specific argument counts, so .session-card:has(#keynote-badge) carries the weight of an ID.

A filter bar with no JavaScript

Here’s where :has() changes how you build things. Waypoint’s filter bar is a set of checkboxes. Because the checkboxes and the cards share an ancestor, .schedule, the state of a checkbox can drive the cards:

HTML
<section class="schedule">
  <div class="filters">
    <label><input type="checkbox" id="hide-full"> Hide sold-out</label>
  </div>
  <article class="session-card">…<span class="badge-full">Sold out</span></article>
  <article class="session-card">…</article>
</section>
CSS
.schedule:has(#hide-full:checked) .session-card:has(.badge-full) {
  display: none;
}

Read it right to left: hide a card that contains a sold-out badge, when it’s inside a schedule that contains a checked #hide-full box. The browser re-evaluates it the moment the checkbox changes.

Three more selectors that replace JavaScript

:has() gets the attention, but a few quieter selectors earn their place on the Waypoint page too:

CSS
/* Highlight the whole filter bar while any control inside it has focus */
.filters:focus-within { outline: 2px solid #a78bfa; }

/* Every card except sold-out and cancelled ones */
.session-card:not(.is-full, .is-cancelled) { cursor: pointer; }

/* Stripe only the visible cards, skipping hidden ones */
.session-card:nth-child(even of :not([hidden])) { background: #f9fafb; }

:focus-within matches an element when it or anything inside it has focus. :not() accepts a whole list, so you no longer chain :not(.a):not(.b). And the of S form of :nth-child() counts only siblings that match S, which fixes zebra stripes that break the moment a filter hides a row. All three work in every current browser.

Your turn

In the exercise you’ll rewrite the card styles with nesting, then add two :has() rules: one that hides sold-out sessions when the filter box is checked, and one that highlights a card when its “save” checkbox is ticked. That completes the cascade section; next you’ll move from which styles apply to where things go, starting with flexbox.

Key takeaways

  • Native nesting groups a component’s states, children and media queries inside its rule, and & stands for the parent selector.
  • A nested selector gets the specificity of :is() applied to the parent list, so a parent list containing an ID makes every nested rule ID-heavy.
  • CSS nesting doesn’t concatenate strings the way Sass does, so &__title will never produce .card__title.
  • :has() matches an element when the relative selector inside it matches, which lets a parent, or an earlier sibling, react to its descendants or later siblings.

Further reading

Check your understanding

4 questions

Question 1 of 4

What selector does this nested rule produce?

CSS
.session-card {
  .speaker & { padding: 0; }
}
Question 2 of 4

Which selector styles a session card only when it contains an image?

Question 3 of 4

Which rule hides every non-design card when the #only-design checkbox inside .schedule is checked?

Question 4 of 4

You write .card { &__title { font-weight: 700; } } out of Sass habit. What happens?

Practice

Code playground

Nest the card and filter with :has()

Three jobs, all in CSS:

  1. Rewrite the three .session-card rules (base, h2, :hover) as one nested rule on .session-card.
  2. When the #hide-full checkbox is checked, hide (display: none) every .session-card that contains a .badge-full.
  3. When a card’s .save checkbox is checked, give that card a #6d28d9 border color.

Don’t add JavaScript or change the HTML.

<section class="schedule">
  <div class="filters">
    <label><input type="checkbox" id="hide-full"> Hide sold-out sessions</label>
  </div>
  <article class="session-card" id="c1">
    <h2>Shipping View Transitions</h2>
    <label class="save-label"><input type="checkbox" class="save"> Save</label>
  </article>
  <article class="session-card" id="c2">
    <h2>Grid Layout Workshop</h2>
    <span class="badge-full">Sold out</span>
    <label class="save-label"><input type="checkbox" class="save"> Save</label>
  </article>
  <article class="session-card" id="c3">
    <h2>Color Spaces for Humans</h2>
    <label class="save-label"><input type="checkbox" class="save"> Save</label>
  </article>
</section>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The card styles use native nesting
  • All cards are visible before filtering
  • Checking the filter hides only the sold-out card
  • Unchecking the filter shows it again
  • A saved card gets a violet border