CSS Specificity Explained: The 4-Tuple, !important, and @layer
How CSS specificity actually works: the 4-tuple scoring system, :where/:is/:not quirks, !important pitfalls, and using @layer to end specificity wars.
CSS Specificity Explained
When two rules target the same element, the browser picks a winner using specificity. Understanding the algorithm turns most CSS mysteries into arithmetic.
Specificity is a 4-tuple
Every selector gets scored as (a, b, c, d), compared left-to-right like version numbers:
- a - inline
style="..."attribute (0 or 1) - b - number of ID selectors (
#header) - c - number of class, attribute, and pseudo-class selectors (
.btn,[type="text"],:hover) - d - number of element and pseudo-element selectors (
div,::before)
The universal selector * and combinators (>, +, ~, whitespace) contribute nothing.
Real numeric examples
* { } /* (0,0,0,0) */
li { } /* (0,0,0,1) */
ul li { } /* (0,0,0,2) */
ul li::before { } /* (0,0,0,3) */
.nav { } /* (0,0,1,0) */
.nav a:hover { } /* (0,0,2,1) */
input[type="text"]:focus { } /* (0,0,2,1) */
#logo { } /* (0,1,0,0) */
#nav .item.active a { } /* (0,1,2,1) */
style="color:red" /* (1,0,0,0) */
A single ID beats 256 classes because the columns are compared, not summed. (0,1,0,0) always wins against (0,0,99,99).
Source order breaks ties
If two selectors have identical specificity, the one declared later in the stylesheet wins:
.btn { color: blue; } /* (0,0,1,0) */
.btn { color: red; } /* (0,0,1,0) - wins, red */
The !important escape hatch
!important promotes a declaration into a separate, higher cascade origin. Order of origins (lowest to highest):
- User-agent normal
- User normal
- Author normal
- Author
!important - User
!important - User-agent
!important - CSS transitions and animations
.btn { color: red !important; } /* beats any non-important author rule */
Avoid it: once you use !important, the only way to override it is another !important, which spirals into an arms race. Prefer restructuring selectors or using layers.
:where(), :is(), :not()
These functional pseudo-classes have surprising specificity rules:
:where(...)- always zero specificity, regardless of arguments. Perfect for defaults you want easily overridden.:is(...)- takes the specificity of its most specific argument.:not(...)- same rule as:is(): most specific argument wins.
:where(#nav, .menu) a { } /* (0,0,0,1) - :where contributes 0 */
:is(#nav, .menu) a { } /* (0,1,0,1) - #nav is most specific */
h1:not(.plain, #hero) { } /* (0,1,0,1) - #hero wins inside :not */
Use :where() to publish resets or utility base styles that consumers can override with a single class.
Cascade layers: the modern fix
@layer creates explicit priority buckets that override specificity entirely. Layers declared later win, regardless of what's inside them:
@layer reset, base, components, utilities;
@layer reset {
#logo { color: black; } /* (0,1,0,0) but in reset layer */
}
@layer utilities {
.text-red { color: red; } /* (0,0,1,0) but in utilities layer - wins */
}
A class in utilities beats an ID in reset because utilities is declared later. Unlayered styles beat all layered styles. !important reverses the layer order (earlier layers win when important).
Layers let you assign intent - resets, framework, components, overrides - without pumping selectors full of IDs. This is now the recommended way to organize large stylesheets.
Debugging checklist
- Open DevTools - the Styles panel shows crossed-out losers and the winning rule.
- Count selector components manually; write the 4-tuple in a comment.
- If you reach for
!important, ask whether a layer or lower-specificity selector would work. - Wrap defaults in
:where()so consumers override without escalation.
Master the tuple, respect source order, and let @layer replace the old specificity wars.