HTML & CSS82 min total · 18 parts
CSS Fundamentals: The Box Model, Specificity, Positioning, and Layout
Part 7 of 18 · ~4 min
Specificity: How CSS Breaks Ties
Whenever two ordinary (non-!important) rules both try to set the same property on the same element, CSS runs a scoring pass over each selector and hands the win to whichever score comes out higher. That score is specificity, built from three separate counts, ranked strongest to weakest:
| Tier | What lands in it | Ranking |
|---|---|---|
An inline style="" attribute | style="color: red" | Outranks every selector written in any stylesheet |
| ID selectors | #legacy-promo | Second-highest |
| Classes, attribute selectors, pseudo-classes | .card, [data-sale="true"], :hover, :nth-child() | Middle |
| Type selectors, pseudo-elements | article, ::before | Second-lowest |
| The universal selector, plus combinators | *, >, +, ~ | Adds nothing at all to the score |
Comparison happens tier by tier, top to bottom, and nothing carries over between tiers: one ID outranks a hundred classes stacked together, and one class outranks a hundred bare element selectors. Only once two selectors land in the same tier does anyone bother counting how many selectors of that tier each one has. Here's the storefront's actual specificity accident: a promo banner from a previous sale left this rule sitting in an old stylesheet, unremoved:
#legacy-promo .price { color: crimson; } /* 1 id, 1 class, 0 elements */
.category-grid .card .price.is-sale { color: var(--accent-color); } /* 0 ids, 3 classes, 0 elements */
The second rule is longer, newer, and was written specifically to color this sale price — and it loses, every time, on every browser. One ID outranks any number of classes stacked against it, no matter how many the losing rule strings together. The lesson isn't about length or effort at all: specificity is scored purely by which tier a selector reaches into, and a selector that never touches the ID tier simply cannot beat one that does.
Two selectors are exceptions to all of this — what's inside their parentheses doesn't score the normal way, and this is exactly where people get caught out:
:is(.card, .rail-item) .description { color: var(--muted-text); } /* :is() borrows the score of whichever argument inside it scores highest — one class */
:where(.card, .rail-item) .description { color: var(--muted-text); } /* :where() always contributes ZERO specificity, no matter its arguments */
The entire reason :where() exists is so a grouped, convenient selector — a reset, a design system's shared base layer — never has to compete on specificity with anything written after it. A single class declared anywhere else in the project outranks a :where(...) block, regardless of how many selectors are packed inside it.
Why this is the real motivation behind BEM
The card's class names have stayed simple so far — .price, .card, .button — and left unchecked, that's exactly how a fight like the #legacy-promo one gets started: once enough ID selectors, long descendant chains, and stray !importants pile up in one project, working out which rule actually wins turns into something you have to compute rather than something you can eyeball.
BEM's whole design goal is making specificity a non-issue. .block__element--modifier names are all flat, single classes by convention — never nested, never compounded with anything else — so two BEM rules aimed at the same element are guaranteed to tie on specificity, leaving source order as the only thing left to decide between them. Renamed for the card:
.card__price { color: var(--text-color); }
.card__price--sale { color: var(--accent-color); }
Both selectors carry identical specificity — one class each — so whichever rule is written later in the stylesheet is the one that applies, and that's a fact you can check by scrolling, not by computing three separate tallies in your head. It's worth being precise about what BEM actually buys you here, though: it disciplines your own team's selectors so they never escalate against each other. It does nothing at all about the #legacy-promo rule from a completely different, older stylesheet — that ID still wins over .card__price--sale for exactly the same reason it won before. BEM prevents you from creating that fight with yourself; it doesn't referee a fight somebody else already started. The actual fix for #legacy-promo is deleting it, or scoping it down, not out-specifying it.