Command Palette

Search for a command to run...

Hectal
PHASE 2Beginner ~22 min· topic 2 of 5

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 that id. 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 p or li. 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).

scores.csswhole filecss
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.

Compare column by columndiagram
Rendering diagram…

03Score the logic pseudo-classes

:is() and :not() take the score of their strongest argument. :where() scores nothing, whatever is inside it.

logic.csswhole filecss
: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.

style.csswhole filecss
/* 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. 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. 2

    Swap :where and :is

    Change :where(#menu) .special to :is(#menu) .special. The Blog link turns crimson, because the ID now counts.

  3. 3

    Count classes against an ID

    In the first example, keep adding .c to the long selector. It never beats #hero. Then change #hero to [id="hero"] and the class selector wins at once.

Code & diagrams

Eleven classes lose to one ID New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

#hero paragraph: crimson
#soft paragraph: seagreen
Score a real menu New tab

Blog 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Home: steelblue
Courses: seagreen
Blog: steelblue
About: darkorange
Where each selector part is counteddiagram
Rendering diagram…

Break 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; }.

terminal
$ the page shows
── what you'll see ──
The button has a green background but gray text. DevTools shows `color: white` crossed out under `.button`.

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.

terminal
$ the page shows
── what you'll see ──
The footer link stays gray, even though the page has no #header inside the footer.

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 nested p, 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. 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). So nav .links a:hover scores (0, 2, 2): two in b (.links, :hover) and two in c (nav, a).

  2. 2

    Some parts score nothing: the universal selector *, the combinators (space, >, +, ~) and the :where() pseudo-class. * > p scores (0, 0, 1), exactly like p.

  3. 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. 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) p scores (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 specific of selector.

  5. 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. 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: a style attribute beats any selector of the same origin and importance.

Explain it without notes

01

What do the three numbers in a specificity score count?

02

How are two specificity scores compared? Why does (0, 11, 0) lose to (1, 0, 0)?

03

What is the specificity of :is(#main, .content) p, :where(#main, .content) p and ul li:not(.done)?

04

Why do many teams avoid ID selectors in their CSS?

Practice

01

Score each selector: article p, .post .title, #main .post, input[type="checkbox"]:checked + label, ul > li::marker.

02

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.