Modern Selectors and Cascade Layers
TL;DR
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.
In depth
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 / edge cases
: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.
What a senior is expected to say 3
- “
:has()is the relational/parent selector — style a parent by its children or an element by its following sibling, killing a lot of class-toggling JS.” - “
:is()takes the highest specificity of its args;:where()is always zero — I use:where()for easily-overridable defaults. Both are forgiving lists.” - “
@layerorders priority bands explicitly so a low-specificity utility beats a high-specificity component — it ends specificity wars. Unlayered styles outrank all layers, and!importantinverts layer order.”
Cross-references
- Specificity/cascade fundamentals: CSS
- Forms styling with
:has()/:user-invalid: Accessible Forms - Design-system layering with tokens: Design Tokens and Theming at Scale
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