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.
Interview Q&A
Q: What does :has() enable?
A: 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.
Q: :is() vs :where() — the difference that matters?
A: 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.
Q: How does :not() improve with selector lists?
A: 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.
Q: What are cascade layers (@layer)?
A: 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.
Q: How do layered vs unlayered styles compare in the cascade?
A: 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.
Q: Are :has() and these selectors a performance concern?
A: 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
- “
: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: README.md
- Forms styling with
:has()/:user-invalid: ../16_accessibility/05_forms_accessibility.md - Design-system layering with tokens: ../08_component_libraries/design_tokens_and_theming.md
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