Modern Selectors and Cascade Layers
Three features that change how you write CSS at scale. :has() is the long-awaited “parent selector” — style an element based on its descendants/siblings. :is()/:where() group selector lists; the key difference is specificity (:is() takes the highest of its arguments, :where() is always zero). @layer (cascade layers) lets you define explicit priority bands (reset → base → components → utilities) so the cascade is driven by layer order, not specificity wars or import order.
What does :has() enable?
Selecting an element based on what it contains or is followed by — previously impossible in CSS:
.card:has(img) { padding-top: 0; } /* cards that contain an image */
label:has(+ input:required)::after { content: " *"; } /* label before a required input */
form:has(:invalid) button[type="submit"] { opacity: .5; } /* disable submit while invalid */
.gallery:has(> :nth-child(10)) { /* layout for 10+ items */ }It’s relational: A:has(B) matches A when B exists relative to it. This removes a huge class of “JS just to toggle a class on the parent” code.
:is() vs :where() — the difference that matters?
Both reduce repetition by accepting a selector list, but their specificity differs:
:is(h1, h2, h3) a { color: blue; } /* specificity = the most specific arg (here, the element + a) */
:where(h1, h2, h3) a { color: blue; } /* specificity = 0 — trivially overridable */:is(...)takes the highest specificity among its arguments.:where(...)contributes zero specificity.
Use :where() for base/reset styles you want easy to override (e.g., a design-system default); use :is() when you want normal specificity. A common gotcha: :is(.a, #b) gets the specificity of #b (an id) even when matching via .a.
How does :not() improve with selector lists?
Modern :not() accepts a list: :not(.x, .y, [hidden]) excludes any of them. Its specificity is the most specific argument. Useful for “all buttons except these,” but deep nesting still hurts readability — prefer a clear class.
What are cascade layers (@layer)?
Named priority bands with an explicit order. Styles in a later layer beat earlier layers regardless of specificity:
@layer reset, base, components, utilities; /* declare order once, low → high priority */
@layer base { a { color: blue; } }
@layer components { .btn a { color: white; } }
@layer utilities { .text-black { color: black; } } /* wins over components even with lower specificity */This tames specificity wars: a low-specificity utility in a higher layer beats a high-specificity component selector. Import third-party CSS into its own layer to keep it from overriding your styles.
How do layered vs unlayered styles compare in the cascade?
Unlayered styles win over all layered styles (they’re treated as the highest layer). Order within a layer is normal cascade (specificity, source order). !important inverts layer order (important declarations in earlier layers win) — a deliberate, confusing-at-first design that keeps important resets authoritative. Practical rule: put everything in layers, or you lose the ordering benefit for the unlayered bits.
Are :has() and these selectors a performance concern?
Historically relational selectors were feared, but modern engines optimize :has() well for typical usage; it’s production-ready. Avoid pathological cases (extremely broad :has() on huge subtrees re-evaluated constantly), but day-to-day use is fine. Don’t pre-optimize by avoiding them.
Gotchas
:is()/:where()are forgiving selector lists — one invalid selector inside doesn’t invalidate the whole rule (unlike a normal comma list, where one bad selector drops everything). Useful for cross-browser pseudo-classes.:is(#id, .class)specificity surprise — it inherits the id’s specificity even when matched via the class; use:where()if you want zero.:has()can’t be nested inside:has()(no:has(:has())), and some pseudo-elements aren’t allowed as arguments.@layerorder is set at first declaration — declare the order up front (@layer a, b, c;) so it doesn’t depend on which file loads first.- Forgetting unlayered styles outrank layers — a stray unlayered rule silently beats your carefully ordered layers.
!important+ layers reverses priority — rarely needed; if you’re reaching for it, reconsider the layering.
Related
Further reading
- MDN —
:has(): https://developer.mozilla.org/en-US/docs/Web/CSS/:has - MDN —
:is()/:where(): https://developer.mozilla.org/en-US/docs/Web/CSS/:where - MDN —
@layer: https://developer.mozilla.org/en-US/docs/Web/CSS/@layer
Interview angle 3
- “Is there a parent selector in CSS?” - yes,
:has()..card:has(img)styles the card by what it contains, andlabel:has(+ input:invalid)styles an element by its sibling’s state. A large amount of class-toggling JavaScript exists only because this did not used to be possible. - “How do
:is()and:where()differ?” - only in specificity.:is()takes the highest specificity of its arguments, so:is(#a, p)is as specific as an id;:where()is always zero. Use:where()for defaults you want anyone to override without an escalation.- Follow-up: “What does ‘forgiving’ mean here?” - one unparseable selector in the list does not invalidate the rest. In a plain comma-separated list, a single selector the browser does not understand throws away the whole rule.
- “What does
@layeractually change?” - it makes priority explicit instead of emergent. Layer order decides the winner before specificity is even consulted, so a one-class utility in a later layer beats a three-class component selector in an earlier one. That is what ends specificity wars, rather than another round of nesting.- Follow-up: “What are the two gotchas?” - unlayered styles beat every
layer, so a stray global rule outranks your whole system; and
!importantinverts layer order, so the earliest layer wins among important declarations.
- Follow-up: “What are the two gotchas?” - unlayered styles beat every
layer, so a stray global rule outranks your whole system; and