CSSGENERALSPEC-EVOLVINGBROWSER-SPECIFIC

Specificity

A three-part tuple compared left to right, not a score. What counts, what deliberately counts as zero, and how :where() and :is() turn specificity into something you choose.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

How exactly does the browser compare two selectors, and how do I stop that comparison from being an argument?

The user intent

Someone wants a variant of an existing component to look different in one place, without breaking it everywhere else, and without leaving a landmine for whoever needs the next variant.

The obvious build

Specificity is a number. Ids are worth 100, classes 10, elements 1; add them up and the bigger number wins. If you need to win, add another class.

Why it breaks

It is not a number in a base, it is a tuple compared component by component. Eleven classes score (0, 11, 0) and still lose to one id at (1, 0, 0), because the comparison stops at the first component that differs.

How it breaks in a real browser
  • It is not a number in a base, it is a tuple compared component by component. Eleven classes score (0, 11, 0) and still lose to one id at (1, 0, 0), because the comparison stops at the first component that differs.
  • The additive mental model predicts that specificity can always be bought with more classes. It cannot: no quantity of classes ever reaches the id column.
  • It attributes weight to things that carry none. The universal selector, combinators, and everything inside :where() contribute exactly zero.
  • It treats :not(), :is() and :has() as if they had their own weight. They have none of their own; they take the specificity of their most specific argument, which means :is(#a, p) is as specific as #a.
  • The advice it produces — add another class — makes the next override harder, so a codebase following it ratchets upward until the only remaining move is !important (The Cascade).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • Specificity is the tuple (a, b, c). a counts id selectors. b counts class selectors, attribute selectors and pseudo-classes. c counts type (element) selectors and pseudo-elements.
  • Comparison is lexicographic: compare a; only if equal compare b; only if equal compare c. There is no carrying and no base, which is why "specificity is base 10" is wrong in exactly the case that matters.
  • Zero-weight by design: the universal selector *, all combinators (>, +, ~, descendant space), and the whole of :where() including everything nested inside it.
  • :is(), :not() and :has() are replaced by the specificity of their most specific argument. :not(.a) is (0, 1, 0). :is(h1, #title) is (1, 0, 0) — even when it matched the h1.
  • :nth-child(n of S) takes the most specific of S on top of the pseudo-class's own weight, which is the one place a structural pseudo-class can smuggle in an id.
  • Not part of specificity at all: !important, inline style attributes, cascade layers, and source order. Each is a *different step* of the cascade sort, and conflating them with specificity is the source of most of the confusion in this area.
  • A selector list separated by commas is not one selector — each complex selector in the list gets its own specificity and competes on its own. #a, p { } gives the p match a specificity of (0, 0, 1).

What this makes the browser do

And which of it is avoidable.

  • Specificity is computed once per selector when the stylesheet is parsed and stored with the rule. It is not recomputed per element, so a "high specificity" selector costs nothing extra at match time.
  • What does cost is the *matching*, which is a separate question and depends on the selector's shape rather than its weight (Selector Matching Cost).
  • Sorting matched declarations by the cascade criteria, of which specificity is one — cheap, because the values were precomputed.
  • The avoidable half: declarations that exist only to win specificity arguments. They are matched, sorted and discarded on every recalculation, forever.

Counting, and what deliberately counts as nothing

Three columns, compared left to right. The examples below are worth reading slowly, because the interesting rows are the ones where the count is lower than it looks.

The second thing to notice is what the table does *not* contain. There is no column for !important, none for inline styles, none for layers, and none for source order. Every one of those is a separate step in the cascade sort, and every one of them is regularly misdescribed as "specificity" in code review.

  • Compare left to right and stop at the first difference. (0, 11, 0) versus (1, 0, 0): the a column already decided it.
  • A comma-separated selector list is several selectors. Each carries its own specificity.
  • :where() is the only way to write a rule with genuinely zero weight, which makes it the right home for resets and defaults.
Selector(a, b, c)Why
*(0, 0, 0)The universal selector contributes nothing at all
li(0, 0, 1)One type selector
ul > li + li(0, 0, 3)Three type selectors; the combinators contribute nothing
.card(0, 1, 0)One class
[data-state="open"](0, 1, 0)Attribute selectors count in the same column as classes
:hover, :focus-visible, :nth-child(2)(0, 1, 0)Ordinary pseudo-classes count as classes
::before(0, 0, 1)Pseudo-*elements* count as type selectors, not as classes
.a.b.c.d.e.f.g.h.i.j.k(0, 11, 0)Eleven classes — and still loses to any single id
#main(1, 0, 0)One id; nothing in column b or c can ever reach it
:where(.a, #b, div)(0, 0, 0):where() zeroes everything inside it, ids included
:is(.a, #b)(1, 0, 0)Takes its most specific argument, even when it matched .a
:not(.a)(0, 1, 0)The pseudo-class itself is free; its argument is not
.card:has(> img)(0, 2, 0)One class, plus :has() taking the weight of its most specific argument
style="color: red"n/aNot specificity — a separate, earlier step of the cascade (The Cascade)

Choosing the weight on purpose

The functional pseudo-classes turn specificity from something that happens to you into something you declare. That is a bigger shift than it sounds: a reset can now be written so that it is guaranteed to lose every argument, and a shared rule can be written so that it wins exactly as much as you intended.

The pattern that pays for itself fastest is a :where()-wrapped reset. Element-selector resets sit at (0, 0, 1), which is enough to beat a zero-weight rule elsewhere and produce a confusing bug once a year for the life of the project. Wrapping them removes that possibility entirely.

The pattern that most often backfires is :is() with a mixed list. It looks like shorthand and behaves like a promotion.

Making a variant win
Raise the ceiling
.card .btn { background: var(--surface); }

/* variant, written to beat it */
.page .card .btn.btn--danger {
  background: var(--danger);
}
Keep the ceiling flat
@layer components, variants;

@layer components {
  .btn { background: var(--surface); }
}

@layer variants {
  .btn--danger { background: var(--danger); }
}

The left version works and sets the price of the *next* variant at four compound selectors. The right version costs one layer declaration and leaves every future variant at one class, because layers are compared before specificity is ever consulted. The difference is not readability, it is whether the cost of the next change grows.

Zero on purpose, and the promotion to watch for
1/* Reset that can never win an argument: (0, 0, 0) */
2:where(h1, h2, h3, ul, ol, figure) { margin: 0; }
3
4/* The old shape: (0, 0, 1). Beats the reset above, and beats any
5 :where()-based default you write later. That is the bug. */
6h1, h2, h3 { margin: 0; }
7
8/* Deliberate shared weight: (0, 1, 0) for every branch */
9.prose :is(h2, h3, h4) { margin-block-start: 1.5em; }
10
11/* The promotion. Reads as convenience; every branch is now (1, 0, 0),
12 so .prose h2 can no longer be overridden by any class-based rule. */
13.prose :is(#lede, h2, h3) { margin-block-start: 1.5em; }
14
15/* Focus, written so it cannot be silently lost.
16 Remove and replace in one rule, at one weight. */
17:where(button, a, [tabindex]):focus-visible {
18 outline: 2px solid var(--focus, currentColor);
19 outline-offset: 2px;
20}

The two h1, h2, h3 rules differ by three characters and by whether a later default can ever override them. That is the whole of :where().

When escalation is actually the right call

There is a version of this lesson that ends "never raise specificity", and it is wrong often enough to be worth spelling out. Sometimes you do not control the other stylesheet, cannot introduce a layer, and need the override today.

The decision below is about what you are buying and what you are mortgaging. Each option ends the argument; they differ in what they cost the next person, and in whether they can be undone.

This rule is losing and it needs to win

What is the cheapest move that does not raise the cost of the next override?

Put the loser in a lower cascade layer

when You control how both stylesheets are included, even if you did not write one of them.

cost Everyone on the team has to understand layers, including the !important reversal. One-time concept cost, permanent benefit (The Cascade).

Lower the winner instead of raising the loser

when The winning rule is yours and is over-specified — a .page .card .btn that only ever needed .btn.

cost A refactor with real regression risk elsewhere; needs visual coverage before you touch it (Visual Regression Testing).

Add one modifier class at the same level

when A genuine variant of a component you own.

cost Nothing structural, as long as it stays at one class and does not become an ancestor chain.

Match the winner's shape exactly and come later in order

when You control build order and the relationship is local and documented.

cost Depends on emitted order, which bundlers and code splitting can change without telling you (Code Splitting).

Raise specificity deliberately

when Third-party CSS you cannot layer, and the override is genuinely one rule.

cost Sets a new floor. Write the reason in a comment, because in six months the shape alone will not explain itself.

`!important`

when A utility that must win by definition, or beating an inline style you cannot remove.

cost Moves the argument into the important band, where layer order reverses. Two of these and you are back where you started, one level up.

How to build it

Most important first.

  • Keep specificity low and flat, then use cascade layers when you need one group of rules to beat another. Layers are compared *before* specificity, so they solve the problem specificity escalation was trying to solve (The Cascade).
  • Wrap resets and defaults in :where() so they contribute zero and can be overridden by anything. :where(ul, ol) { margin: 0 } is a reset that never wins an argument it should not be in.
  • Use :is() deliberately when you *do* want a shared, higher weight, and remember it takes the most specific argument — putting an id in the list raises the weight for every branch.
  • Express variants with a state or modifier class at the same level, not with a longer ancestor chain. .btn--danger and .btn at (0, 1, 0) each are resolved by order or layer; .page .card .btn is a commitment.
  • Avoid id selectors in stylesheets. Ids are fine as hooks for JavaScript and fragment links; as style selectors they create a column nothing else can reach (Queries, Live Collections and Stale References).
  • When you inherit a codebase with an escalation problem, do not level up — put the offending styles in a low layer and rebuild above them. That resets the ceiling instead of raising it.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Focus styles lose specificity arguments more than any other single category. A *:focus { outline: none } reset at (0, 1, 0) beats a :where(:focus-visible) ring at (0, 0, 0), and the result is a page that cannot be navigated by keyboard (Keyboard Operability).
  • If you must remove a default outline, replace it in the same rule and at the same weight. A removal and a replacement written in two places is a bug waiting for a refactor (Focus Management).
  • State selectors — :disabled, :checked, [aria-expanded="true"], :invalid — each add to the b column, which means state styling naturally out-specifies base styling. That is usually what you want; notice when it is not.
  • Never encode a state only in a class that CSS reads while the DOM says nothing. [aria-expanded="true"] styles the same element assistive technology is reading, so the visual and the announced state cannot drift (The Rules of ARIA).
  • Contrast rules written as overrides can lose. If a high-contrast or forced-colors adjustment is losing on specificity, it is invisible to exactly the users it exists for (Contrast, Colour and Motion).

What can go wrong

Failure modes
  • A "safe" reset written with element selectors that beats a :where()-based one somewhere else, silently, because (0, 0, 1) beats (0, 0, 0).
  • :is() applied to a list containing one id, raising the weight of every other branch to id level. The selector reads as convenience and behaves as escalation.
  • A utility class that cannot do its job because the component it is applied to uses a two-class selector. This is the canonical argument for layers.
  • Specificity used as encapsulation — .my-widget .title to avoid collisions — which works until another team does the same thing one level deeper.
  • The mitigation failing too: wrapping everything in :where() means *nothing* has weight, and now every conflict is decided purely by source order, which the bundler controls (Code Splitting).
Security
  • Specificity is not a boundary. Any stylesheet in the document, including a third-party one, can write a higher-weight selector than yours (Third-Party Scripts and the Supply Chain).
  • A selector containing an id derived from user-controlled content can be made not to match by changing that content — a weak but real way to disable a style that was doing something protective, like an overlay guard.
  • Attribute selectors on user-controlled attributes combined with a resource request are the classic CSS-based exfiltration primitive. Specificity has nothing to do with it; the lesson is that selectors read the DOM (Cross-Site Scripting).
  • Real encapsulation is shadow DOM, which is a different cascade step entirely — compared at step 2, before specificity is ever reached (Shadow DOM and the Composed Tree).
Misreads
  • "Specificity is a base-10 number." It is a tuple compared component by component. Ten classes never become an id.
  • "!important is maximum specificity." It is not specificity at all; it is a different comparison, one that happens five steps earlier (The Cascade).
  • ":not() adds specificity." The pseudo-class contributes nothing itself; its most specific argument does.
  • "Lower specificity is always better." Lower specificity plus no layer discipline just moves the argument to source order, which is decided by your build.
  • "Adding a class is harmless." Every class you add to win an argument is one more class the next person must clear, and it is permanent.

Measuring it, and what changes in the field

How you would see this
  • The Styles pane shows losing declarations struck through and, on hover, the specificity of each selector. That is the ground truth; hand-counting is where errors come from (A Mental Model of the Devtools).
  • Specificity graphs — plotting selector weight against source order across a stylesheet — make an escalating codebase obvious at a glance. Spikes late in the file are the ones to look at.
  • !important count over time is the leading indicator. It rises before anyone reports a problem.
  • A visual regression suite catches the class of failure where lowering specificity in one place changes something unrelated (Visual Regression Testing).
Slow device, slow network, large data, old tab
  • In a small codebase with one author, specificity barely matters — source order handles everything and the escalation never starts.
  • In a design system consumed by teams you do not control, the weight of your selectors is a public API. Ship low and flat, because every consumer override has to clear whatever you set (Design Systems).
  • In a codebase mixing a utility framework with hand-written components, specificity conflicts are systematic rather than occasional, and layers are effectively mandatory.
  • With CSS-in-JS or scoped-style tooling, generated selectors usually land at one or two classes, which mostly removes the argument — and replaces it with source-order questions determined by component mount order (Reactivity Models).
What this costs
  • Flat, low-specificity CSS is easy to override, which also means easy to override *by accident*. You trade predictable precedence for a reliance on layers and order that must be maintained.
  • :where() gives you zero-weight rules and takes away any ability for those rules to win. A reset that must hold in some case cannot live there.
  • Refusing id selectors entirely is a good default that occasionally costs you a genuinely convenient hook, and forces a class that duplicates an id already in the markup.
  • Layers solve the argument at the cost of a concept every contributor must learn, including the counter-intuitive reversal under !important.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALThe (a, b, c) tuple and its lexicographic comparison are specified in CSS Selectors and are implemented identically in Blink, Gecko and WebKit. Cross-browser differences in this area are essentially always differences in what matched, not in how specificity was calculated.
  • SPEC-EVOLVINGThe zero-specificity and take-the-most-specific-argument behaviours came with the functional pseudo-classes: :where() and the current :is() semantics are relatively recent, and :has() newer still. Older browsers that predate them drop the entire rule as unparseable rather than ignoring the unknown pseudo-class, so a :has() rule is not a progressive enhancement unless you wrote a fallback.
  • BROWSER-SPECIFICDevtools surface specificity differently: Chrome shows the tuple in a tooltip on the selector, Firefox annotates and warns about unmatched or overridden declarations more aggressively, and Safari shows the least. Team habits form around whichever browser people debug in.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Domains that do not exist yet
  • Software Design — naming and encapsulation as an alternative to precedence. A specificity war is usually a missing boundary rather than a missing selector.