◐ ai-teacher

🎨 Frontend & the Browser · Chapter 1

The cascade, specificity, and why your rule lost

Your CSS is not broken. It is being beaten — by a more specific rule, or by one further down the file. Those are the only two ways to lose.

Study this lesson free in the app →

The basics

The cascade is the rule the browser uses to pick one winner when several CSS rules set the same property on the same element.

The problem it solves: a real page has thousands of rules from your stylesheet, components and browser defaults, and many of them overlap. Without a fixed tie-breaker, the same page would look different on every browser. The cascade makes the result predictable — if you know how to read it.

The mental model: your rule can only lose in two ways. Either another rule is more specific (its selector describes the element more precisely), or it is equally specific and written later. Everything else — !important, origins — is rare. A media query only decides whether a rule is considered at all; it adds no strength.

Specificity is counted as (IDs, classes, elements) and compared left to right, so #nav (1,0,0) beats .a .b .c .d (0,4,0).

Words you must know 5

Specificity
the selector's score: IDs, then classes/attributes/pseudo-classes, then elements
Source order
on a tie, the later rule wins
Shorthand
margin, background: sets and resets every longhand inside it
Inheritance
some properties (and every --custom-property) flow down to children unless a child declares its own
Computed style
the final value the browser actually paints

Explained

You add a rule. Nothing happens.

You add !important. It works. You move on, and the next person inherits a stylesheet where nothing can be overridden by anything.

There is a two-step check that tells you the real reason, every time.

When two rules set the same property on the same element, the browser picks a winner in this order:

  1. Origin — your stylesheet beats the browser default
  2. !important — jumps the queue (this is why it is a trap, not a tool)
  3. Specificity — how precisely the selector describes the element
  4. Source order — if everything above ties, the one written last wins

Almost every real-world confusion is step 3 or step 4. So the debugging question is only ever: is my selector less specific, or is it earlier in the file?

Counting specificity

Specificity is three numbers, (A, B, C), compared left to right:

A — inline style="" attributes B — IDs (#sidebar) C — classes, attributes, pseudo-classes (.card, [data-open], :hover) D — elements and pseudo-elements (div, ::before)

| Selector | Score | | --- | --- | | p | 0,0,1 | | .card | 0,1,0 | | .card p | 0,1,1 | | .view[data-chrome="page"] | 0,2,0 | | #sidebar | 1,0,0 |

A higher B beats any number of Cs: one ID outranks eleven classes. And crucially — a media query adds nothing. @media (min-width: 900px) { .view { … } } is still just 0,1,0.

The layout rule lived inside a media query:

@media (min-width: 900px) {
  .view { margin-left: var(--side-w); }   /* 0,1,0 */
}

And elsewhere, for centring page-type screens:

.view[data-chrome="page"] { margin: 0 auto; }  /* 0,2,0 */

0,2,0 beats 0,1,0, so the margin shorthand won — and margin resets margin-left to 0. The sidebar was 268px wide and the content started at zero.

The instinct is !important. The fix was to state the value on the selector that was already winning:

.view[data-chrome="page"] { margin: 0 auto; margin-left: var(--side-w); }

Two lessons, both cheap to learn once: a media query does not raise specificity, and a shorthand quietly resets the longhands it contains.

Handbook panels were meant to be colour-coded by tone. Every heading rendered the same blue.

The tone was set on the parent as a custom property:

.hb-panel { --t: var(--accent); }            /* the fallback */
.hb [data-tone="good"] { --t: var(--good); } /* the override */

But the child heading also declared a fallback:

.hb-panel-head { --t: var(--accent); }   /* ← reset the inherited value */

A custom property inherits — until a descendant sets its own. That fallback on the child overwrote the tone coming down from the parent, on every panel.

The rule that came out of it: set a custom property only on the element that carries the data, and let children inherit. There is now a browser test asserting at least three distinct heading colours on the page.

The two-step diagnosis

In DevTools, click the element and look at the Styles panel. Your rule will be in one of three states:

Struck through → something more specific, or later, won. Hover it — DevTools names the winner. Compare the two selectors and count.

Not listed at all → the selector does not match. Typo, wrong nesting, or the element is not what you think.

Listed and applied, but wrong result → the property is not the one that matters. Something else — a shorthand, a parent's display, an inherited custom property — is doing it.

That third case is the one that costs hours, because the CSS looks correct.

Reaching for !important. It ends the argument for that property permanently. The only defensible use is overriding a third-party stylesheet you cannot edit.

Assuming a media query wins. It does not add specificity. If the rule outside it is more specific, it wins at every screen size.

Shorthand stomping longhand. margin clears margin-left. background clears background-color. font clears line-height.

Ordering theme overrides too early. This file puts light-theme rules at the very end on purpose — they override component rules, so they have to come after them.

Fighting the cascade with more nesting. .a .b .c .d wins today and is unoverridable tomorrow.

Two ways to lose: less specific, or earlier in the file.

Count (inline, IDs, classes, elements). A media query adds nothing.

When your rule is struck through in DevTools, you are not looking at a bug — you are looking at the answer.

Words to know

CascadeThe algorithm that picks a winner: origin, then !important, then specificity, then source order.
SpecificityA score from the selector — inline, IDs, classes, elements — compared left to right.
ShorthandA property that sets several longhands at once. It resets every longhand it covers, including ones you did not mention.
Custom propertyA --variable. It inherits down the tree until a descendant declares its own value.
Computed styleThe final value after the cascade has resolved. What getComputedStyle returns, and what the browser actually paints.

In two minutes

Test yourself

1. Does wrapping a rule in a media query increase its specificity?

  1. Yes, media queries take priority
  2. No — specificity comes only from the selector
  3. Only for min-width queries
  4. Only in light mode
Show the answer

No — specificity comes only from the selector — A media query decides whether a rule is considered. Once it is, it competes purely on selector specificity and source order.

2. Which selector wins: .view[data-chrome="page"] or .view inside a media query?

  1. The media query rule
  2. .view[data-chrome="page"] — 0,2,0 beats 0,1,0
  3. Whichever is later
  4. They tie
Show the answer

.view[data-chrome="page"] — 0,2,0 beats 0,1,0 — The attribute selector adds another class-level component. This exact mismatch broke the desktop layout in this app.

3. Why did every handbook panel heading render the same colour?

  1. The tone data was missing
  2. A child declared its own --t, overriding the inherited one
  3. Media query conflict
  4. The colours were identical
Show the answer

A child declared its own --t, overriding the inherited one — Custom properties inherit until a descendant sets its own. The child's fallback silently discarded the parent's tone.

4. What is the specificity of .nav a:hover?

  1. (0,1,1)
  2. (0,1,2)
  3. (1,1,1)
  4. (0,2,1)
Show the answer

(0,2,1) — .nav is a class (0,1,0), :hover is a pseudo-class and counts in the same column (0,1,0), a is an element (0,0,1). Total (0,2,1). Forgetting that pseudo-classes count like classes gives the tempting (0,1,1).

5. .btn { color: white; } is on line 40 of the stylesheet and .btn { color: black; } is on line 200. What colour is the button?

  1. Black — equal specificity, so the later rule wins
  2. White — the first definition wins
  3. It depends on the browser
  4. Neither — the conflict resets it to inherit
Show the answer

Black — equal specificity, so the later rule wins — Both are (0,1,0), so the cascade falls through to source order and the later declaration wins. This is completely deterministic across browsers.

6. :where(#main .card) p { color: red; } comes first; p { color: blue; } comes later. What colour is a paragraph inside #main .card?

  1. Red — the selector contains an ID
  2. Red — it's the more specific selector
  3. Blue — `:where()` contributes zero specificity, so both are (0,0,1) and the later rule wins
  4. Blue — element selectors always win
Show the answer

Blue — `:where()` contributes zero specificity, so both are (0,0,1) and the later rule wins — :where() is designed to add nothing, however strong its contents. So the first selector scores only its p: (0,0,1). Tie → source order → blue. :is() is the opposite: it takes the specificity of its strongest argument.

There are 8 questions, 3 puzzles and 6 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →