CSS from First Principles

Lesson 1 of 5

The Cascade, Specificity & Inheritance

"Simplicity does not precede complexity, but follows it." — Alan Perlis, Epigrams on Programming (1982)

CSS is the language people claim not to respect and cannot reliably predict. Both things are true at once, and they are related: it looks like a list of declarations, so nobody expects it to have semantics worth learning, so the semantics get learned by collision. The result is an engineer who can write a distributed queue and cannot say why their color is being ignored.

What is actually happening is a resolution algorithm. When several declarations target the same property on the same element — which is the normal case, not the exception — the browser runs a defined sequence of tie-breakers to pick one. That sequence is the cascade, and specificity is only one step inside it. Inheritance is a separate mechanism that runs after resolution, for properties where the cascade produced no answer at all.

Treating these as three rules of thumb is what produces the folk practices: !important sprinkled to win arguments, selectors lengthened until they stick, #id used for weight rather than identity. Treating them as one algorithm makes the behaviour predictable, and predictable CSS is the entire difference between a stylesheet that survives three years and one that gets rewritten.

The specific correction most engineers need is this: specificity is not a number. It is a three-part tuple compared left to right, and there is no rollover. Eleven classes do not beat one id — not because eleven is less than some threshold, but because the comparison never reaches the class column when the id column already differs. The block below demonstrates it directly, and once you have seen it, an entire category of "why isn't this working" resolves itself.

The big question

Two rules set color on the same element. One has eleven class selectors; the other has a single id. The eleven-class rule appears later in the file. It loses. Why is that not just "ids are stronger", and what does the real answer tell you about every other conflict you have argued with?

The cascade is an ordered algorithm

Specificity is step four of seven. That framing alone fixes several misconceptions.

Rendering diagram…
Tests — Run & Watch Them Pass

Run to execute the tests…

The inversion in the second test is the part people are never told. !important was designed so a user could override an author — an accessibility mechanism. In author stylesheets it is a borrowed weapon.

Everyday analogy

Think of a court hierarchy. A dispute is settled at the lowest level that can decide it: if the first test separates the parties, nothing below it is ever consulted. Specificity is one court in that hierarchy, not the courthouse. When people say "just add another class", they are arguing louder in a court that already ruled — the case was decided upstairs on origin, or on layers, and no amount of extra classes reaches it. And when the columns compare left to right with no rollover, that is the same principle again: once the id column differs, the class column is not evidence, it is noise.

Specificity, computed

Tests — Run & Watch Them Pass

Run to execute the tests…

The final test is the everyday version of this. A hover state that will not apply is almost always losing to an id somewhere else in the file, and no amount of classes on the hover rule will reach it. The fix is deleting the id from the other selector, not strengthening this one — which is why id selectors are discouraged for styling. They create conflicts that cannot be resolved locally.

Play with it

Try it Yourself
Loading editor…
Result

Make the second selector .nav.nav.nav.nav a:hover and watch it still lose. Then change the first to :where(#header) .nav a and watch it flip instantly. That is the whole lesson in two edits.

Inheritance runs where the cascade found nothing

Tests — Run & Watch Them Pass

Run to execute the tests…

The fourth test is the one that catches people. A blanket * { color: … } reset is a declaration on every element, so it defeats inheritance everywhere — including the theming you wanted to flow down. Inheritance loses to any declaration, however weak. It is not competing on specificity; it only runs when there is nothing to compete with.

A little history

Håkon Wium Lie's original 1994 proposal was more radical than what shipped: styles from the author and the reader were to be blended by weight, so a user could declare "font-size: 24pt, 60% weight" and get a genuine negotiation between their needs and the designer's intent. The word cascading is the residue of that idea — it was about streams of preference merging, not about a priority list. Implementers found percentage blending impossible to specify unambiguously, and CSS1 replaced it with the discrete origin ordering we have, keeping !important as the one surviving trace of user sovereignty. That history explains the design that otherwise looks arbitrary: !important inverts author and user precedence because the whole feature exists so a reader can insist. Using it to win an argument with your own stylesheet is using an accessibility mechanism as a hammer, and it works — right up to the point where you need to override it and discover you spent the last move.

The engineer's notebook

The rule I hold to hardest is a specificity ceiling: single classes for almost everything, no ids in selectors ever, and when a rule genuinely needs scoping, :where() so the scope costs nothing. A stylesheet where nearly every rule sits at [0,1,0] is one where source order decides conflicts — and source order is something you can see and reason about, unlike a specificity war spread across six files. The second habit is what I do when a rule will not apply: before touching the selector at all, I open devtools, find the property, and look at what is crossed out above it. The answer is in that panel every single time, and it is frequently not what I assumed — often an inline style or a third-party stylesheet, neither of which more classes would have fixed. The mistake I made for years was treating a losing rule as a signal to make it stronger, when it is almost always a signal that something else is too strong. Strengthening is local and feels productive; it also raises the floor for everyone who comes after, and a codebase where every rule was strengthened once is a codebase that now needs !important to do anything at all.

In the real world

A design system team I worked with could not get their button component to accept an override. Consumers would pass a class, it would do nothing, and the escalation path had become "add !important" — which by the time I saw it appeared in about forty places across the consuming applications. The root cause was one line written two years earlier: the component's styles were scoped as #app .ds-button, because at the time someone needed it to beat a global reset and reached for the nearest tool. Every consumer class sat at [0,1,0] against the component's [1,1,0], so nothing ever worked, and each team independently discovered !important as the workaround. Removing the #app prefix and re-scoping with :where(#app) .ds-button fixed all forty cases at once, because the scope still matched and now weighed nothing. The uncomfortable part of the story is the interval: two years, dozens of engineers, all reaching for the same hack, and not one of them looked at what they were losing to. The devtools panel had the answer the whole time.

Thought experiment

Suppose specificity did not exist and conflicts were resolved purely by source order — last rule wins, always. What would break? Component libraries mostly would not: you would import them first and your overrides would naturally come later. Utility classes would get simpler, not harder. What you would lose is the ability for a specific rule to beat a general one written later in the same file, which is real but narrower than it sounds. Now notice that this is very close to what people already do voluntarily — flat single-class conventions and cascade layers are both, in effect, ways of turning specificity off so that order can decide. That convergence is worth sitting with. The most common way to succeed with a powerful feature is to stop using most of it, and if a whole industry's best practice is "opt out of this mechanism", the interesting question is what the mechanism was for.

Common Mistakes

  • Reading specificity as a three-digit number. It is a tuple compared left to right; eleven classes never reach an id.

  • Adding classes to a rule that is losing to an id. The comparison stops at the id column. Remove the id instead.

  • Using ids as styling hooks. They create conflicts that cannot be resolved locally by anyone downstream.

  • Reaching for !important to win. It changes origin bucket, not specificity — and two !important rules fall straight back to specificity anyway.

  • Not knowing !important inverts author and user. It exists so a reader can insist, which is why taking it is taking a user's tool.

  • Ignoring :where(). Zero specificity with a full match; it is the answer to almost every "I need to scope this but stay overridable".

  • Grouping an id into :is() for brevity. :is() and :not() take their strongest argument, so everything in the list is silently promoted.

  • Believing inheritance competes with the cascade. It only runs when the cascade produced nothing; any declaration beats it.

  • Blanket * resets on inherited properties. A declaration on every element defeats inheritance everywhere, breaking theming in untraceable ways.

  • Expecting box properties to inherit. Only text-ish properties do — inherited padding would nest catastrophically.

  • Guessing instead of opening devtools. The crossed-out declaration tells you the winner every time, and it is regularly something you did not suspect.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
How does the browser decide which of two conflicting CSS rules applies?
Model answer — It runs a sequence of tie-breakers and stops at the first one that separates them. Origin and importance first — user-agent, then user, then author, with !important reversing that order. Then cascade layers, then inline styles, then specificity, and if everything is still tied, source order, where the last rule wins. Specificity itself is a tuple of three counts: ids, then classes plus attribute selectors plus pseudo-classes, then element types plus pseudo-elements. The key thing is that it's compared left to right with no rollover — so a single id beats any number of classes, because once the id column differs the comparison stops and the class column is never read. That's the part I'd emphasise, because thinking of it as a number like 011 versus 100 gives you the right answer by coincidence and the wrong mental model.
Intermediate
A hover style isn't applying. It's the last rule in the file. What's going on?
Model answer — Source order is the last tie-breaker, so being last only matters if everything before it tied — which means something earlier is winning on specificity, layers, origin, or an inline style. The classic version is a hover rule at .nav a:hover, which is [0,2,1], losing to something like #header .nav a at [1,1,1]. It looks wrong because the hover rule is more specific in plain English, but the id column decides it before the class column is read. What matters is that you can't fix this locally: adding more classes to the hover rule keeps it at zero ids, so it never gets there. The fix is removing the id from the other selector, or wrapping it in :where() so it still matches and weighs nothing. In practice I'd open devtools first and look at the crossed-out declaration rather than reasoning about it, because it's often something I didn't suspect — an inline style, or a third-party sheet. And the general lesson is that a losing rule usually means something else is too strong, not that this one is too weak. Strengthening it feels productive and raises the floor for everyone after you.
Advanced
How would you keep specificity under control in a large, long-lived codebase?
Model answer — By deliberately flattening it so that source order does the deciding, because order is visible and specificity wars are not. Concretely: a ceiling of one class per rule for the vast majority of styles, no ids in selectors at all, and :where() whenever something genuinely needs scoping — it matches fully and contributes zero, which is exactly what you want for a component boundary. Then cascade layers for the architecture: a defined @layer order like reset, base, components, utilities means a utility beats a component regardless of selector shape, and you've replaced an implicit war with an explicit declaration at the top of the file. Layers resolve above specificity, so a single class in a later layer beats anything in an earlier one — which is the property that makes utility classes work without !important. I'd ban !important in application code with a lint rule, allowing it only for overriding third-party stylesheets you can't edit, because the real cost isn't the rule itself, it's that it removes the ability to override later — you've spent your last move. The failure I'd be designing against is the one I've seen most: a component scoped with an id two years ago, every consumer failing to override it, and each team independently discovering !important. That's forty broken workarounds from one line, and the tell is that nobody ever looked at what they were losing to.
Company lens — how the same topic gets asked
micro1Live: given conflicting rules, say which wins and why — then fix one that should be winning.
GoogleAlgorithmic: the full cascade order, where layers sit, and how :is()/:where() compute.
MetaDesign systems: keeping components overridable across many consuming teams.
AmazonDebugging: a style that won't apply in production — walk through the diagnosis.
Hands-on

Open your own stylesheet and compute the specificity of every selector — the model above is about twenty lines and will run over a real file. Sort by the id column and look at what comes out on top; those are your future override problems, and there are usually more than you expect. Then take the highest one and try rewriting it with :where() so it matches identically at zero weight, and check nothing moved. Next, do the diagnosis drill: find a style in your app that is being overridden, open devtools, and identify the winner from the crossed-out declaration before forming a theory. Do that five times and you will notice how often the answer is somewhere you were not looking. Finally, add up your !important declarations and, for each one, work out what it is beating — if the answer is your own stylesheet, that is a specificity problem wearing a disguise.

Tests — Run & Watch Them Pass

Run to execute the tests…

Summary

  • The cascade is an ordered algorithm, and specificity is one step inside it — after origin, layers and inline styles.
  • Origin order is UA → user → author, and !important reverses it. It exists so a reader can insist over a designer.
  • !important changes origin bucket, not specificity. Two important rules fall straight back to specificity, which is why the arms race cannot be won.
  • Specificity is a tuple, not a number. Compared left to right, no rollover: [0,11,0] loses to [1,0,0] because the id column decides it first.
  • * and combinators weigh nothing. Attribute selectors and pseudo-classes weigh the same as a class.
  • :where() matches fully at zero weight — the tool for scoping something that must stay overridable.
  • :is() and :not() take their strongest argument, so grouping an id into a list silently promotes everything in it.
  • A rule losing to an id cannot be fixed locally. Remove the id or wrap it in :where().
  • Inheritance is a fallback, not a competitor. It runs only where the cascade produced nothing, and any declaration beats it.
  • Only text-ish properties inherit; box properties do not, because inherited padding would nest catastrophically.
  • A blanket * reset defeats inheritance everywhere, because it is a real declaration on every element.
  • inherit, initial, unset and revert give you explicit control; all: unset is the cleanest way to strip UA styling.
  • Open devtools before theorising. The crossed-out declaration names the winner every time.

In the next chapter, The Box Model, we look at what a rule actually produces once it has won: how a box is sized, why box-sizing changed the default everyone uses, and where margins collapse into each other for reasons that are entirely deliberate.

Editorial reflection

Writing this chapter, the thing I kept circling is that almost every best practice in CSS architecture — flat single classes, no ids, BEM, utility frameworks, cascade layers, :where() — is a strategy for using less of the cascade. That is unusual. We do not normally respond to a powerful feature by building conventions to avoid it. My reading is that the cascade was designed for a web of documents where an author, a reader and a browser each had legitimate claims on the rendering, and it is genuinely well designed for that. What it was not designed for is a hundred engineers editing one stylesheet across five years, where any mechanism allowing a distant file to override a local decision is a liability rather than a feature. So the conventions are not fixing a flaw; they are adapting a document language to a software-engineering problem it never anticipated. I find that a more useful frame than "CSS is bad", partly because it is fairer and mostly because it tells you what to do: prefer the parts of the cascade that are visible and local, and opt out of the parts that act at a distance.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.