Topic 2.2
Specificity in Depth
In one line
Specificity is a three-number score (IDs, classes, types) the browser gives every selector; when origin, importance and layer tie, the higher score wins, compared column by column from the left.
Think of it like this
Delivering a letter. "To the person in the blue house" could mean several people. "To the person in the blue house on Park Street" is more precise. "To Asha Rao, 12 Park Street" points at exactly one person. If three letters give you different instructions, you follow the one with the most precise address. Specificity is how the browser measures how precise a selector's "address" is.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Specificity
- A score (a, b, c) for how precisely a selector points at an element.
- ID selector
- A selector that starts with
#, like#menu. It matches the element with thatid. Counts in column a. - Class selector
- A selector that starts with
., like.note. Counts in column b. - Type selector
- An element name used as a selector, like
porli. Counts in column c. - Pseudo-class
- A keyword after a colon that matches elements in a state or position, like
:hover. Counts in column b. - Pseudo-element
- A part of an element, written with two colons, like
::before. Counts in column c. - Combinator
- The symbol between two selectors that says how they relate: a space,
>,+or~. Scores nothing. - Specificity war
- When developers keep writing stronger selectors (or
!important) to beat each other's rules.
Step by step
01Count the IDs, classes and types
To score a selector, read it from left to right and put each simple part in a column. Ignore spaces, >, +, ~ and *.
#nav .item a::before: one ID (#nav) goes in a, one class (.item) in b, one type (a) and one pseudo-element (::before) in c. Score: (1, 1, 2).
p /* (0, 0, 1) */
.note /* (0, 1, 0) */
p.note /* (0, 1, 1) */
nav ul li a /* (0, 0, 4) */
.menu > li:hover a /* (0, 2, 2) */
a[href^="https"] /* (0, 1, 1) */
#nav .item a::before /* (1, 1, 2) */
* + * /* (0, 0, 0) */02Compare column by column
Which wins: nav ul li a (0, 0, 4) or .links a (0, 1, 1)? Column a ties at 0. Column b: 1 beats 0. Done: .links a wins. The 4 in column c never mattered.
Which wins: .a.b.c.d.e.f.g.h.i.j.k (0, 11, 0) or #x (1, 0, 0)? Column a: 1 beats 0. The ID wins.
03Score the logic pseudo-classes
:is() and :not() take the score of their strongest argument. :where() scores nothing, whatever is inside it.
:is(#main, .content) p /* (1, 0, 1): #main is the strongest argument */
:where(#main, .content) p /* (0, 0, 1): :where adds nothing */
a:not(.internal) /* (0, 1, 1): :not itself adds nothing, .internal does */
.card:has(> img) /* (0, 1, 1) */
li:nth-child(odd of .item) /* (0, 2, 1) */04See the ID problem
A page has #sidebar a { color: gray; }. Later, someone adds a button-style link <a class="button"> in the sidebar and writes .button { color: white; }. The button text stays gray: (1, 0, 1) beats (0, 1, 0).
The tempting fixes (#sidebar .button or !important) make the next override even harder. The real fix is to remove the ID from the styling.
/* before: one ID poisons every override */
#sidebar a { color: gray; } /* (1, 0, 1) */
.button { color: white; } /* (0, 1, 0): loses */
/* after: classes only, easy to override */
.sidebar a { color: gray; } /* (0, 1, 1) */
.sidebar .button { color: white; } /* (0, 2, 0): wins */05Keep specificity low and flat
Good CSS keeps most selectors at one or two classes. Then overrides stay simple: a later rule with the same score wins by order, or one extra class wins by specificity.
When you must target an element by id without its heavy weight, use an attribute selector: [id="sidebar"] scores (0, 1, 0), like a class.
Try it yourself
- 1
Score before you run
In the menu example, add
nav .links li a { color: tomato; }at the end of the CSS. Score it first: (0, 1, 3). Which links change? Predict, then run. (Home and Blog turn tomato: (0, 1, 3) beats (0, 1, 1). Courses keeps (0, 2, 1) and About keeps (1, 1, 0).) - 2
Swap :where and :is
Change
:where(#menu) .specialto:is(#menu) .special. The Blog link turns crimson, because the ID now counts. - 3
Count classes against an ID
In the first example, keep adding
.cto the long selector. It never beats#hero. Then change#heroto[id="hero"]and the class selector wins at once.
Code & diagrams
Repeating the same class (`.c.c`) is valid CSS and counts once per repeat. It is a known trick for raising specificity without IDs, but use it sparingly.
Preview
Console
Nothing logged yet.Expected console output
#hero paragraph: crimson
#soft paragraph: seagreenBlog stays steelblue: `:where(#menu)` hides the ID's weight, so (0, 1, 0) loses to (0, 1, 1). About turns orange: `:is(#menu)` keeps the ID's weight.
Preview
Console
Nothing logged yet.Expected console output
Home: steelblue
Courses: seagreen
Blog: steelblue
About: darkorangeBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
The ID that won't let go
Style sidebar links with #sidebar a { color: gray; }, then add <a class="button">Sign up</a> in the sidebar with .button { color: white; background: seagreen; }.
Break #2
The :is() that made everything heavy
Write :is(#header, .footer) a { color: gray; } to share one rule, then try .footer .cta { color: tomato; } for a footer call-to-action link.
Myth vs fact
Myth
Ten classes beat one ID because 10 > 1.
Fact
Columns never carry over. (0, 10, 0) is less than (1, 0, 0) because column a is compared first.
Myth
A selector list like h1, .title has one combined score.
Fact
Each selector in a comma-separated list is scored on its own, and only the one that matches the element counts.
Myth
:not() and :is() score like a pseudo-class.
Fact
They score like their most specific argument. :not(#x) adds an ID's worth. Only :where() always scores zero.
Myth
Longer selectors are always stronger.
Fact
html body div ul li a is (0, 0, 6) and still loses to .link (0, 1, 0).
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Browsers store specificity as one packed integer per selector, computed once when the stylesheet is parsed. Specificity costs nothing at match time; long descendant selectors cost more because of matching work, not because of their score.
- ▸
In CSS nesting (Phase 11), the nesting selector
&behaves like:is()wrapped around the parent's selector list. So#a, .b { & p { color: red; } }gives every nestedp, even one inside.b, the score (1, 0, 1). Nesting a rule under a list that contains an ID quietly makes the whole group heavy. - ▸
Utility-first CSS (like Tailwind) keeps almost every selector at (0, 1, 0), so order of appearance decides conflicts and the framework controls that order. Many design systems wrap their base styles in
:where()so app code can override them with a single class. - ▸
DevTools shows specificity when you hover a selector in the Styles pane (Chromium and Firefox), so you never have to count by hand when debugging.
Remember this
- 1
Specificity is written as three numbers (a, b, c). a counts ID selectors (
#menu). b counts class selectors (.note), attribute selectors ([type="email"]) and pseudo-classes (:hover,:first-child). c counts type selectors (p,li) and pseudo-elements (::before). Sonav .links a:hoverscores (0, 2, 2): two in b (.links,:hover) and two in c (nav,a). - 2
Some parts score nothing: the universal selector
*, the combinators (space,>,+,~) and the:where()pseudo-class.* > pscores (0, 0, 1), exactly likep. - 3
Scores are compared column by column, from the left, like comparing words in a dictionary. First compare a: the bigger one wins, and the other columns are never looked at. Only if a ties do you compare b, and only if b ties do you compare c. There is no carrying over: (0, 11, 0) is less than (1, 0, 0). Eleven classes never add up to an ID.
- 4
The logic pseudo-classes from Topic 1.5 follow special rules.
:is(),:not()and:has()add the specificity of their most specific argument::is(#main, .content) pscores (1, 0, 1) even when it matches through.content.:where()always adds zero, which makes it the tool for writing easy-to-override defaults.:nth-child(2n+1 of .item)counts as one pseudo-class plus its most specificofselector. - 5
Why IDs cause trouble: a single ID gives a selector a score that no amount of classes can beat. Once a stylesheet has
#sidebar a { color: gray }, every later change to those links needs another ID or!important. Teams end up in a specificity war, each fix stronger than the last. That's why most style guides say: style with classes, keep IDs for JavaScript hooks, in-page links (href="#top") and form labels. - 6
Inline styles (
style="...") are often described as specificity (1, 0, 0, 0), "a fourth column on the left". The modern specification treats them as their own cascade step that comes before specificity (Topic 2.1). Either way the result is the same: astyleattribute beats any selector of the same origin and importance.
Explain it without notes
What do the three numbers in a specificity score count?
How are two specificity scores compared? Why does (0, 11, 0) lose to (1, 0, 0)?
What is the specificity of :is(#main, .content) p, :where(#main, .content) p and ul li:not(.done)?
Why do many teams avoid ID selectors in their CSS?
Practice
Score each selector: article p, .post .title, #main .post, input[type="checkbox"]:checked + label, ul > li::marker.
The HTML is <div class="card"><h2 class="card-title">Hi</h2></div> and the CSS is .card h2 { color: gray; } followed by .card-title { color: navy; }. Which colour shows? Rewrite the second rule so navy wins, with classes only.
Trade-offs
- ↔
Low, flat specificity (one class per selector) makes overrides easy but relies more on source order; deep selectors protect styles from accidental overrides but make intentional ones painful.
- ↔
:where()makes defaults trivially overridable, which is great for libraries but means any stray rule can change them;:is()keeps grouped rules as strong as their strongest part.
Done when you can
I can score any selector as (a, b, c).
I can compare two scores column by column.
I know the special rules for
:is(),:not(),:has()and:where().I can explain why IDs make CSS hard to override.
I can fix a losing rule by adjusting specificity instead of using
!important.