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.
- 1Native nesting
- 2:has(): style an element by what it contains
- 3A filter bar with no JavaScript
- 4Three more selectors that replace JavaScript
- 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 {
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:
/* 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:
<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>.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:
/* 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
&__titlewill 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.