skip to content

You maintain a shared CSS component library used by other teams. How do you author its default rules so a consumer's own single-class rule can override them, without them resorting to `!important` or copying your selector structure?

level: seniorimportance: should knowfreq 40%

answer

  1. defaults should apply but lose
  2. weight zero beats no one and loses to everyone
  3. coarse control versus per-rule control
  4. the layer name is public API
  5. configuration beats escalation

basics

~20 s

Ship defaults at zero or near-zero cascade weight: wrap the library's selectors in :where() so they contribute nothing to specificity, or publish the library inside a low cascade layer. Either way a consumer's plain class wins without escalating.

solid answer

~50 s

The goal is that consumers never have to think about your selectors at all. Two mechanisms do that. Wrapping a default in `:where()` — `:where(.dialog) { padding: 1rem; }` — makes the whole selector contribute zero specificity, so literally any authored rule beats it. Or ship the library inside a declared cascade layer: for normal declarations, unlayered consumer styles outrank anything in a layer, and a consumer who uses layers can place their own after yours. `:where()` has been in all the major browsers since early 2021 and `@layer` since 2022, so both are realistic today. In practice I use layers for the library as a whole and `:where()` for the individual rules I most expect people to replace, and I document the layer name as part of the public contract — because once consumers rely on it, changing it is a breaking change.

code

css · 10 lines
css
@layer library {
  :where(.dialog) {
    padding: 1rem;
    background: canvas;
  }
}

.dialog {
  padding: 2rem;
}

go deeper

for a junior

Know that a library's styles can be authored so they are easy to override, and that forcing consumers to write !important is a design failure on the library's side.

for a middle

Explain the two mechanisms concretely: :where() drops a selector's contribution to zero, and unlayered consumer styles outrank layered library styles for normal declarations.

for a senior

Show that you treat override-ability as a designed interface — decide which rules are freely overridable, which are structural, and how the choice survives an unknown consumer bundler.

for a principal

Own it as a versioned contract across teams: the layer name and order are public API, changing them breaks consumers, and configuration via custom properties should absorb the cases you can predict.

## The problem being solved A library author has an asymmetry problem. You want your defaults to apply out of the box, but you do not want them to be *hard to beat*, because everything consumers do to beat them becomes weight in their codebase that they can never remove. If your dialog is styled as `.dialog .dialog__header h2`, then every consumer who wants a different heading colour writes something heavier, and your library is the reason their stylesheet is on the escalation ladder. So the design target is: defaults that apply, but lose to anything intentional. ## Mechanism one — zero-weight selectors `:where()` matches its argument but contributes nothing to specificity: ```css /* Applies, but any authored rule beats it */ :where(.dialog) { padding: 1rem; border-radius: 0; } :where(.dialog) :where(h2) { margin-block: 0; } ``` A consumer's `.dialog { padding: 2rem; }` now wins regardless of where it loads, because the library's rule has weight zero and any real selector beats zero. This is precise, works per-rule, and needs nothing from the consumer's build. It has been available across the major browsers since early 2021. The cost is that it applies to *everything* in the parentheses, so you must be deliberate. A rule you genuinely do not want overridden by accident — an internal structural rule that makes the component function — should keep normal weight. ## Mechanism two — cascade layers ```css @layer library { .dialog { padding: 1rem; } } ``` For normal declarations, unlayered author styles take precedence over any layered ones, whatever their weight. So a consumer who writes plain CSS beats your layered defaults without doing anything at all; a consumer who uses layers themselves can declare an order that puts theirs after yours. Layers landed in Chrome 99, Firefox 97 and Safari 15.4, all in 2022. The cost is coordination. Layer priority depends on the order the layer *names* are first encountered, which depends on how your stylesheet is included in the consumer's bundle. That makes the layer name and its expected position part of your public API — document it, and treat renaming it as a breaking change. ## Which to use They compose, and the honest answer is both. Layers handle the coarse question — the whole library sits below consumer code. `:where()` handles the fine one — specific rules, especially ones that touch elements the consumer's own selectors will naturally target, drop to zero so no ordering assumption is needed at all. One caution: layers do not neutralise weight *within* a layer. If your library contains both `.dialog` and `#app .dialog`, consumers are shielded from your library's weight relative to their code but your own internal rules still fight each other. Flat authoring inside the library is still required. ## What not to do - **Do not ship `!important` defaults.** It forces every consumer onto importance permanently. - **Do not rely on load order alone.** "Just import our stylesheet first" is not enforceable; bundlers, code splitting and lazy-loaded chunks all reorder things. Weight and layers survive reordering, comments do not. - **Do not encode your DOM in selectors.** `.dialog > .dialog__body > p` couples consumers to internal structure and hands them a heavy rule to fight. - **Do not expose an override surface only through selector escalation.** Where a value is genuinely meant to be configured, a custom property is a better contract than a selector, because the consumer sets a value instead of winning a fight. ## The contract framing The deeper point is that override-ability is a public interface. A library that ships at zero weight has decided that consumers own the final word; a library that ships heavy rules has decided the opposite, usually by accident. State the decision explicitly in the docs — which layer the styles land in, which rules are zero-weight and therefore freely overridable, and which are structural and expected to stay. Consumers can then plan their own architecture around it instead of discovering the answer through a failed override.

  • If the library already sits in a low cascade layer, why bother with :where() as well?
    Because layer priority depends on where the layer name is first declared in the consumer's final bundle, which you do not control. `:where()` needs no cooperation at all — a zero-weight rule loses to any authored selector regardless of layers, bundling or import order. Layers give coarse, whole-library separation; `:where()` gives a per-rule guarantee that survives a misconfigured build.
  • Are there library rules that should deliberately keep normal specificity?
    Yes — rules the component needs in order to work rather than to look a certain way: an overlay's stacking and positioning, a scroll container's overflow behaviour, hit-area sizing. Accidentally overriding those breaks the component rather than restyling it, so keeping them at ordinary weight is a useful speed bump. Document them as structural.
  • Where do custom properties fit into this?
    They replace the override entirely for values you intend to be configurable. Reading `var(--dialog-padding, 1rem)` in the library lets a consumer set the variable on any ancestor and win by inheritance, with no cascade contest at all. Use properties for the intentional configuration surface, and zero-weight defaults for everything else people might reasonably restyle.

saying these in an interview costs you the question

  • Ships library defaults with !important to guarantee they apply
  • Assumes consumers will import the library stylesheet first
  • Confuses :where() with :is(), which does carry specificity
  • Thinks a cascade layer flattens specificity inside the layer
  • Encodes internal DOM structure into public selectors

context