frontend / css / 03_modern_selectors_and_layers.md

Modern Selectors and Cascade Layers

4 min read source

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.
  • @layer order 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.”
  • @layer orders priority bands explicitly so a low-specificity utility beats a high-specificity component — it ends specificity wars. Unlayered styles outrank all layers, and !important inverts layer order.”

Cross-references

Further reading