The Cascade and Specificity
Date: 2026-09-27
When two declarations set the same property on the same element, the browser runs a fixed sequence of tie-breaks — origin and importance, layer, specificity, order — and stops at the first one that separates them. “Why isn’t this applying” is always answered by finding which step decided it.
The cascade is the algorithm that picks one winning value per property per element from every declaration that matches it. Specificity is one step in it — a score for how precisely a selector targets an element — and it only gets consulted if the earlier steps tie.
The sequence
Each step only runs if every step above it tied.
1 ORIGIN + IMPORTANCE browser defaults < user styles < your styles
!important reverses the order (user !important beats yours)
2 CONTEXT styles inside a shadow root vs outside it
3 STYLE ATTRIBUTE style="…" beats any selector
4 LAYER @layer order — later layer wins, unlayered beats all layers
5 SPECIFICITY the (id, class, type) compare below
6 ORDER OF APPEARANCE last one in source wins
In practice nearly every fight is between your own stylesheets, so steps 1–3 tie and the answer is in 4, 5 or 6. Layers sitting above specificity is the big change of recent years: a one-class selector in a later layer beats an ID selector in an earlier one — Cascade Layers.
Specificity as a tuple
Not a number. Three columns, compared left to right, and a higher column wins no matter how big the columns to its right are.
selector (ID, CLASS, TYPE)
p (0, 0, 1)
.card p (0, 1, 1)
.card .title (0, 2, 0)
#main p (1, 0, 1)
.a.b.c.d.e.f.g.h.i.j.k (0, 11, 0) ← still loses to #main p
nav ul li a:hover (0, 1, 4)
[type="text"] (0, 1, 0) attribute = class column
::before (0, 0, 1) pseudo-element = type column
* (0, 0, 0)
Eleven classes never beat one ID. It’s not base-10 addition — the old “IDs are worth 100” explanation breaks exactly here.
The functional pseudo-classes are where people get caught:
:where(.card .title) (0, 0, 0) ← always zero, whatever is inside
:is(.title, #hero) (1, 0, 0) ← takes its MOST specific argument
:not(#hero) (1, 0, 0) ← same rule
:has(> img) (0, 0, 1) ← same rule
:where() is the tool for defaults you want to be trivially overridable — a reset or a library’s base styles written in :where() lose to any single class. :is() looks similar and does the opposite: one ID in its list makes the whole selector ID-strength.
Inheritance is a different mechanism
The cascade only chooses between declarations that match the element. If nothing matches, an inheritable property (colour, font, line-height — not margin, border, width) takes the parent’s computed value. An inherited value loses to any matching declaration, however weak — * { color: black } at (0,0,0) beats a color: red inherited from #main. That’s the usual answer when a parent’s styles “should” be applying and aren’t.
Debugging it
DevTools’ Styles panel lists every matching declaration in cascade order, with losers struck through. Look at why the winner won:
- Struck through by something in a different layer → step 4. Specificity is irrelevant
- Same layer, higher specificity → step 5
- Same specificity, later file → step 6. Common when bundling changes stylesheet order between dev and production
- Nothing struck through, value still wrong → it’s inherited, or the declaration is invalid (greyed with a warning icon), or it matches a different element than you think — DevTools
Failure modes
- Specificity wars. Beat
.nav awith.header .nav a, then that with#site .header .nav a, then!important. Each fix raises the floor for the next. The structural fix is keeping specificity flat and letting layers or order decide — CSS Architecture !importantas an override tool. It moves the declaration into a different origin band, so the only thing that beats it is another!important— the war resumes one level up. Legitimate for utilities that must always win, and for user-facing accessibility overrides- Order dependence across files. Two selectors of equal specificity in different files: whichever the bundler emits last wins, and it can differ between routes if CSS is split — Code Splitting
- Styling by ID. One
#idselector puts that rule out of reach of every class-based override in the codebase
Where it connects
- The CSSOM — where the cascade actually runs, and why the browser won’t paint until it can
- Cascade Layers — step 4, made deliberate
- Custom Properties — cascade like any other property, which is what makes them work for theming