PROJECT 4
×00

How CSS Specificity and the Cascade Work

Part 3 · Frontend Fundamentals

You change a color. Nothing happens. You add a class. Still nothing. You add !important, it works, you feel a bit dirty, and you move on. Two months later the file has forty of those and nobody can change anything without breaking something else.

Everyone has done this. It comes from thinking about CSS one rule at a time, when the browser is doing something else entirely: for every property on every element, it gathers all the declarations that could apply and runs a contest to pick one winner. If you know how that contest is judged, the fix is usually one small change instead of an escalation.

The contest, in the order it is actually judged

Most people know specificity and assume that is the whole story. It is one step, and it comes late. The browser works down this list and stops as soon as it has a winner.

  1. Origin and importance. Your styles beat the browser's defaults. Anything marked !important jumps to a different, higher contest.
  2. Inline styles. A style attribute on the element beats anything from a stylesheet.
  3. Layers. If you use @layer, the later layer wins, whatever the selectors look like.
  4. Specificity. Only now do we count selectors.
  5. Source order. Still tied? The last one written wins.

Read that list twice, because it explains nearly every confusing case. If a rule with one class beats your rule with three, no amount of specificity work will help. Something higher up the list already settled it, and you are arguing at the wrong step.

A diagram headed "Specificity is step
  four. Most arguments were already lost at step one." Five boxes run left to right: origin and
  importance, inline styles, cascade layers, specificity, and source order. A rail under the first
  three reads: if it was decided here, raising specificity does nothing. A second rail under step four
  reads: where people start. Below, a table shows specificity counted in three columns: an id with a
  class and an element scores 1,1,1 and wins outright, while eleven classes score 0,11,0 and still
  lose; a :where() rule counts as 0,0,1 and an :is() containing an id counts as 1,0,1. Two boxes at
  the foot contrast the escalation, where !important moves a rule into a higher contest and layer
  order flips, with the way out: decide the order with @layer, keep base styles at zero with :where(),
  and read the computed value.
Specificity is step four. Most arguments were already lost at step one.

Counting specificity without the folklore

Specificity is three numbers, not one score. Count each selector into three buckets:

Then compare left to right, and stop at the first difference.

#sidebar .card h2      →  1, 1, 1
.page .card .title     →  0, 3, 0
nav ul li a            →  0, 0, 4

The first one wins, because it has an id and the others have none. Note what does not happen: the columns never carry. Eleven classes is 0, 11, 0, and a single id still beats it. That is the piece of folklore worth unlearning: people talk about specificity as though it were a number you could add up, and it is not.

Two more that catch people out. The universal selector * counts nothing. And :not(), :is(), and :has() do not count themselves, but they take the specificity of the most specific thing inside them, so :is(#main, .card) counts as an id.

Two tools that let you write plain selectors again

The reason specificity turns into a fight is that people raise it to win, and then have to raise it again to win the next one. Two modern selectors let you stop.

:where() counts as zero. Everything inside it is free.

:where(.prose) a { color: var(--link); }   /* 0,0,1; trivial to override */

That is what you want for defaults and resets. Anything a person writes later beats it without effort, which is exactly the behavior a base layer should have.

:is() groups selectors but takes the highest specificity inside, so use it to shorten lists, not to lower weight.

:is(h1, h2, h3) + p { margin-top: 0; }

Between them, most of my reset and base styles now sit at zero or near it, and I almost never need to raise a selector to beat my own defaults.

@layer is the real fix

Layers let you decide the winning order up front, before specificity is even consulted. You declare the order once, and then a rule in a later layer beats a rule in an earlier one no matter how the selectors compare.

@layer reset, base, components, utilities;

@layer base {
  #article h2 { color: navy; }     /* 1,0,1; high specificity */
}

@layer utilities {
  .text-red { color: red; }        /* 0,1,0; and it wins anyway */
}

That single-class utility beats the id selector, because layers are judged before specificity. This is the thing to reach for when you are fighting a framework or an inherited stylesheet: put their styles in an early layer and yours in a later one, and the fight ends.

Anything not in a layer beats everything that is, which sounds odd until you use it. Unlayered styles are effectively the last layer. It is a sensible default for a small project adding layers gradually.

@layer has been safe to use everywhere since 2022. There is also @scope, which confines rules to part of the page, and it finished its browser support at the end of 2025 and reached the safe-to-use mark in January 2026. It is genuinely useful, just newer, so weigh that against the devices you support.

Three live contests, each with competing rules
  on the left and the rendered result on the right. In the first, a selector scoring 1,1,1 beats one
  scoring 0,3,0 and the heading renders amber, decided at step four. In the second, a single-class rule
  in a later layer scoring 0,1,0 beats an id selector scoring 1,0,1 in an earlier layer, and the
  heading renders red, decided at step three. In the third, a rule wrapped in :where() scoring 0,0,1
  loses to a plain class scoring 0,1,0 and the link renders green. Under each result the page prints
  what getComputedStyle returned.
Three contests, each decided at a different step, and each result read back from getComputedStyle.

Those three results are not asserted, they are measured. The page runs getComputedStyle on each element when it loads and prints the winning color underneath, which is how you can see that the single-class utility genuinely does beat the id selector.

Where !important actually belongs

!important does not "raise" a rule. It moves it into a separate, higher contest, which is why nothing normal can beat it and why the only reply is another !important. That is the escalation, and it is why the file ends up with forty of them.

Inside that higher contest, layer order flips: an important declaration in an earlier layer beats an important one in a later layer. That is deliberate, so a reset layer can hold a line against utilities, but it means you cannot reason about the important cases with the same picture as the normal ones.

I keep it for two things: overriding a third-party stylesheet I cannot edit, and a genuine utility that must always apply. Everywhere else it is a sign I lost the argument at step one and should look higher up the list.

What this has to do with agents

Two things, and both come up constantly.

Agents escalate. Ask one to fix a style that will not apply and you will get !important, or a longer selector, or both. It works in the moment and it costs you later, because the next change now has to beat that too. When I see that in a diff, I ask the real question instead: which declaration is winning, and at which step? Usually the honest fix is a layer, or lowering a base rule with :where().

Agents also produce selectors that quietly cancel each other out. Ask for a lot of CSS in one pass and you get overlapping rules, one setting section padding by element, another by class, where the winner depends on source order nobody chose deliberately. The page looks subtly wrong and the cause is not visible in any one rule. This is worth knowing by name, because it is invisible until you check the computed value.

Which is the habit that matters most here. Do not guess which rule won. Open the element in devtools and look at the computed value. The crossed-out declarations tell you the whole story in about five seconds. Every time I have been sure I knew why a style was not applying and skipped that step, I have been wrong.

Asking for it properly

Vague:

The heading color isn't applying, can you fix it?

You will get !important.

Specific:

Don't use !important and don't lengthen the selector. Work out which declaration is winning and at which step of the cascade. If it's a base style fighting a component, wrap the base rule in :where() to drop it to zero. If it's a third-party stylesheet, put it in an earlier @layer than ours. Tell me which step decided it.

That last sentence is the useful one. Asking for the reason gets you a fix that holds, and it tells you whether the agent actually diagnosed the problem or just applied force to it.

The short version

Specificity is not a score, it is three columns that never carry. It is also not the first thing the browser checks: importance, inline styles, and layers all come first, which is why raising specificity so often does nothing. Keep base styles at zero with :where(), decide the order deliberately with @layer, save !important for stylesheets you cannot edit, and when something surprises you, read the computed value instead of guessing.

That closes this short run on fundamentals. The three pieces (flex or grid, the tab key, and this one) share one idea: knowing the names is what lets you ask for the thing you actually want, from a person or from an agent, instead of approving whatever comes back.