Skip to main content
ANVISoftware Solutions
Lesson 6 of 20Beginner16 min

The Cascade and Specificity

By the end of this lesson

Explain why a rule did not apply, and fix it without resorting to !important.

Sooner or later you write a rule, reload, and nothing changes. The style is in the file. The selector looks right. Something else is winning.

CSS has an exact procedure for deciding which value applies when several rules set the same property on the same element. Knowing the procedure turns this from guesswork into a two-minute check in the browser's inspector.

When two declarations conflict, the browser works through these in order and stops as soon as one settles it:

  1. Origin and importance

    Where the declaration came from — the browser's defaults, the reader's own settings, or your stylesheet — combined with whether it is marked !important. Your normal styles beat browser defaults. A reader's important styles beat yours, which is deliberate: someone who needs larger text must be able to have it.

  2. Specificity

    How precisely the selector describes the element. Count ids, then classes and attributes and pseudo-classes, then element names. Compare the counts left to right: a single id beats any number of classes, and a single class beats any number of element names.

  3. Source order

    If specificity ties, the declaration that appears later wins. Later in the same file, or in a file linked further down. This is the only one of the three that most people expect, and it is the last to be consulted.

Specificity as three numbers, written here as (ids, classes, elements):

li
(0, 0, 1) — one element name.
.expense-row
(0, 1, 0) — one class. Beats any selector made only of element names.
.expense-row:hover
(0, 2, 0) — a class and a pseudo-class count the same.
input[type="date"]
(0, 1, 1) — an attribute selector counts as a class, plus one element name.
#expense-form .amount
(1, 1, 0) — one id and one class. Beats (0, 9, 0).
style="..." on the element
Higher than any selector in a stylesheet. This is one reason inline styles are awkward to work with.
:where(...)
Adds no specificity at all, whatever is inside it. Useful for writing defaults you fully intend other rules to override.
Why the rejected row is not red
CSS
/* Written first, in components.css */
#expense-table .expense-row {
  color: #1f2933;      /* (1, 1, 0) */
}

/* Written later, in status.css */
.expense-row.is-rejected {
  color: #b42318;      /* (0, 2, 0) */
}
  • Both rules match a rejected row, and both set colour.
  • Same origin, neither is important, so the tie-break moves to specificity.
  • The first has one id, the second has none. Compare the id count first: 1 beats 0, and the comparison stops there. The extra class in the second rule is never considered.
  • Being written later does not help, because source order is only consulted when specificity ties.
  • So the row stays dark grey. Nothing is broken — the first rule is a more specific claim about the same element.

Four honest fixes for that example, roughly in order of preference:

  • Lower the winner. Change #expense-table .expense-row to .expense-table .expense-row, making it (0, 2, 0) so source order decides and the later status rule applies
  • Wrap the base rule in :where(), which strips its specificity to zero and makes it a true default
  • Put the base rule in a cascade layer declared before your component layers, so later layers win regardless of specificity
  • Match the winner: write .expense-table .expense-row.is-rejected. It works, but you have accepted the escalation rather than removed it

Summary

  • Conflicts are settled by origin and importance, then specificity, then source order — in that sequence
  • Specificity compares ids, then classes and pseudo-classes, then element names, and stops at the first difference
  • A rule that appears later still loses to a more specific rule written earlier
  • !important wins today and forces every later override to escalate, so keep it for narrow, deliberate cases
  • The element inspector shows which rule won and which declarations were overridden — read it before guessing

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Work out the winner

Three rules all set the background of the same approved expense row, in this file order:

1. .expense-row { background: white; }

2. #expenses .expense-row.is-approved { background: #eaf7ee; }

3. .expense-row.is-approved { background: #d9f2e2; }

Which colour appears, and why? Then change one thing so rule 3 wins, without using !important.

Show solution

Rule 2 wins, giving #eaf7ee. Its id count is 1 and the others are 0, so the comparison ends immediately — the fact that rule 3 comes later never matters.

The cleanest change is to drop the id from rule 2, making it (0, 2, 0). Rules 2 and 3 then tie on specificity, source order applies, and rule 3 wins because it comes later.

A second defensible answer is to keep the id but move it inside :where(#expenses), which contributes nothing to specificity. That keeps the scoping intent visible while removing the weight. Either is better than escalating rule 3 to match.

Try it yourself

Make an override that keeps working

Write a small stylesheet for expense rows with a base style and three status variants, arranged so that adding a fourth status later needs no changes to the existing rules and no !important.

Show solution

The move that makes this work is keeping every selector at the same specificity, so source order alone decides. Base rules first, variants after.

:where() on the base rule makes the intent explicit: this is a default, and it is meant to be overridden. Nothing in the variants needs to know how the base was written.

The trade-off is that a zero-specificity base can be overridden by accident as well as on purpose. In a small stylesheet that is fine. In a large one, cascade layers give you the same ordering with clearer boundaries.

CSS
/* Base: deliberately weak, meant to be overridden */
:where(.expense-row) {
  background: #ffffff;
  color: #1f2933;
  border-left: 3px solid transparent;
}

/* Variants: all (0, 1, 0), so order decides and additions are safe */
.expense-row-draft {
  background: #f7f8f9;
  color: #52606d;
}

.expense-row-submitted {
  border-left-color: #2f6fed;
}

.expense-row-approved {
  border-left-color: #1a7f4b;
}

/* Adding .expense-row-rejected later needs no edits above */

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

#expense-table .row sets colour first; .row.is-rejected sets a different colour later in the same stylesheet. Which wins, and why?
Why is !important a poor default answer to a rule that will not apply?

Saved in this browser only.