w3tweaks.com · CSS Tutorial

CSS Specificity Explained

Calculate any selector score, run live battles, and master modern :is() / :where() / @layer rules.

Tab 1

Live Specificity Calculator

Type any CSS selector to instantly see its (ID, Class, Element) score broken down token by token.

Quick examples
Score
ID (A)
0
-
CLASS (B)
0
-
ELEMENT (C)
0
Relative weight
Token breakdown
Why 11 classes never beats 1 ID: Specificity is NOT a decimal number. The three columns are compared independently, left to right — like comparing version numbers. (1,0,0) always beats (0,99,99) because the first column wins before the others are even looked at. You cannot "overflow" column B into column A no matter how many classes you stack.
Tab 2

Selector Battle — Which One Wins?

Paste two selectors targeting the same element. See the scores, see the winner, and understand exactly why.

Try a preset battle
Selector A
ID
0
-
CLASS
0
-
EL
0
VS
Selector B
ID
0
-
CLASS
0
-
EL
0
Tab 3

Modern Selectors & @layer — What Changes

The specificity rules for modern CSS selectors are different from what most tutorials teach. And @layer bypasses specificity entirely.

:is() — takes the highest specificity

:is() takes the specificity of its most specific argument, not an average. This surprises developers who expect it to add nothing.

/* :is(#nav, .link) → score of #nav = (1,0,0) */
:is(#nav, .link) p {
  color: red;
}
/* Even when .link matches, score is still (1,0,1) */
Score:
1
-
0
-
1
(from #nav + p)
:where() — always zero specificity

:where() was built to be overridable. It contributes 0 to specificity regardless of what's inside — making it the perfect escape hatch for design systems.

/* :where(#nav, .link) → score (0,0,0) */
:where(#nav, .link) p {
  color: blue;
}
/* Even #nav inside :where() adds nothing */
Score:
0
-
0
-
1
(only p counts)
:not() — takes argument's specificity

:not() itself adds nothing — but its argument's specificity is counted. This means :not(#id) adds (1,0,0) to the score.

/* :not() adds nothing, argument does */
p:not(.active) {
  /* score: (0,1,1) — .active + p */
}
p:not(#id) {
  /* score: (1,0,1) — #id + p */
}
p:not(.active):
0
-
1
-
1
:has() — takes argument's specificity

Like :not(), :has() contributes the specificity of its most specific argument. The parent selector penalty is zero.

/* .card:has(img) → (0,1,0) + (0,0,1) */
.card:has(img) {
  /* score: (0,1,1) */
}
.card:has(#hero) {
  /* score: (1,1,0) */
}
.card:has(img):
0
-
1
-
1

🏆 @layer — Beats Specificity Entirely

Cascade layers resolve before specificity. A low-specificity rule in a later layer always wins over a high-specificity rule in an earlier layer — no matter what.

1st
@layer base
#hero .title { color: red; }  ← score (1,1,0)
lowest priority
2nd
@layer components
.title { color: blue; }  ← score (0,1,0)
mid priority
3rd
@layer overrides
p { color: green; }  ← score (0,0,1)
✓ WINS
  Result: green — @layer order beats (1,1,0) specificity

Layer order is declared with @layer base, components, overrides; — last layer has highest priority. This is how design systems escape specificity wars without writing !important.

!important is not specificity: !important doesn't change a rule's specificity score. It moves the declaration into a separate cascade origin — the "important" origin — which is evaluated before the regular author origin. Two !important declarations are then resolved by specificity, but !important always beats non-important declarations regardless of score. This is why the fix to !important is never more !important.
Read the tutorial