Skip to content
install

notes 4 min css

note

Which CSS rule wins, and why it is not the specific one

Nearly everyone reaches for specificity first, and nearly everyone is starting three steps too late. Specificity is the fourth tie-breaker, and by the time the cascade gets to it most of the interesting decisions have already been made.

The cascade is a sort, and the sort has a fixed order. Each step only runs when the one before it ties:

  1. Origin and importance. Author, user, and user-agent styles - and !important flips the order of all three rather than adding weight to one.
  2. Layer. @layer order, with unlayered styles last, which means they win.
  3. Specificity. The tuple everybody starts with.
  4. Source order. The last one written, and only if all three above tie.

three rules, one element, and the winner is not the specific one

#page .title { color: red } specificity 1,1,0
@layer base { .title { color: green } } in a layer
.title { color: blue } wins

an unlayered rule beats every layered one, whatever its specificity

The most specific selector on the list loses, twice over. Layer order is checked before specificity, and unlayered styles are checked after every layer.

Layers invert the intuition, on purpose #

@layer is the only part of modern CSS that makes a less specific rule reliably win, and that is the whole feature. Layers are ordered by their first declaration, later layers beat earlier ones, and anything outside a layer beats everything inside one.

the order is set once, at the top, and never argued with again
@layer reset, vendor, base, components, utilities;

@layer vendor {
  #widget .button { padding: 12px 20px; }
}

@layer utilities {
  .p-0 { padding: 0; }
}

/* .p-0 wins. Specificity 0,1,0 against 1,1,0,
   and it does not matter: utilities is the later layer. */

This is the answer to third-party CSS that ships #id .class selectors. Put their stylesheet in an early layer and their specificity stops mattering - no !important, no counter-escalation, no #app #app .thing.

The catch is worth stating plainly: !important reverses layer order too. An important declaration in the earliest layer beats an important one in the latest. It is consistent - importance always runs backwards - and it is the one part of this that surprises people twice.

Specificity is a tuple, not a number #

The classic misreading is treating it as a base-ten score, so eleven classes sound like they should beat one id. They do not. It is three slots compared left to right, and a higher slot cannot be reached from a lower one, ever.

0, 1, 0 beats 0, 0, 11
One class beats eleven element selectors. There is no accumulation across slots.
:where() is free
Contributes zero, whatever is inside it. :where(#app) .card has the specificity of .card alone - the tool for writing a defensible default.
:is() and :not() take the highest
The specificity of their most specific argument. :is(#app, .card) costs an id, which catches people who reached for it to keep things low.
Inline styles are above the tuple
Not a fourth slot - a different origin. Only !important reaches past one.
the same rule, written to be overridable
/* Hard to override: costs an id */
#app .card { padding: 16px; }

/* Same match, costs one class */
:where(#app) .card { padding: 16px; }

/* Same match, costs nothing at all */
:where(#app .card) { padding: 16px; }

Reading it off a live page #

The property that is winning can be read directly, and the answer is often that neither rule you were comparing is involved:

what is actually applied, and everything that tried
const el = document.querySelector('.card');

getComputedStyle(el).padding;

[...document.styleSheets]
  .flatMap((sheet) => [...sheet.cssRules])
  .filter((rule) => rule.selectorText && el.matches(rule.selectorText))
  .map((rule) => [rule.selectorText, rule.style.padding])
  .filter(([, value]) => value);

Three answers come out of that surprisingly often: an inherited value nobody set on this element, a shorthand overwriting a longhand written after it, and a rule inside a media query that is not matching at the width being tested.

The shorthand one deserves its own warning. padding: 16px written after padding-left: 0 resets the left value, and the two lines can be in different files a hundred rules apart. It is not a specificity problem and no amount of specificity reasoning will find it.

What to do instead of escalating #

  • Declare layer order once, at the top of the entry stylesheet, before any of them are used. Order is set by first appearance, so declaring the list up front is what stops it drifting.
  • Put third-party CSS in an early layer. It is the cheapest structural fix in a codebase carrying a widget you do not control.
  • Wrap defaults in :where(). A default that is easy to override is a default people keep.
  • Treat !important as a bug report. It works, and it converts a cascade problem into a cascade problem that is now unreachable from anywhere.

The measure of a stylesheet is not how few overrides it has. It is whether the next person can override something without escalating, and layers are the first tool in twenty years that makes that a design decision rather than a negotiation.

Written by Ján Turský

Building LoupeKit and other browser tools out of Bratislava, under Apptiary.

run it here, no install

tools in this note