notes 4 min css
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:
-
Origin and importance. Author, user, and user-agent styles - and
!importantflips the order of all three rather than adding weight to one. -
Layer.
@layerorder, with unlayered styles last, which means they win. - Specificity. The tuple everybody starts with.
- 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
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.
@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) .cardhas the specificity of.cardalone - 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
!importantreaches past one.
/* 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:
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
!importantas 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