How CSS Works
Chapter 1 — How CSS Works: Cascade, Inheritance, and Specificity
CSS stands for Cascading Style Sheets — and that single word, cascading, describes the entire engine that decides which rule wins when multiple rules compete. Understanding this engine is what separates developers who fight CSS from those who reason about it. This chapter is a deep, authoritative treatment of all three mechanisms: the cascade, inheritance, and specificity.
1. The Cascade — The Full Algorithm
When the browser needs to apply a property to an element, it collects every declaration that targets that element and could set that property. It then runs those declarations through a filter in strict priority order. The first filter that produces a single winner stops — the rest are discarded. Only if all filters tie does the next one apply.
Declarations are ranked by who wrote them and whether they carry
!important.
The full priority order (lowest to highest):
- User-agent stylesheet (browser defaults)
- User stylesheet (browser accessibility overrides)
- Author stylesheet (your CSS)
- Author
!important - User
!important(accessibility wins over author !important) - User-agent
!important
@layer)If
@layer is used, declarations in later-declared layers beat those in
earlier-declared layers. Un-layered styles beat all layers. This is a relatively new
addition to the cascade (covered in depth in the CSS Advanced course).
With
@scope (very new — late 2023+), rules in a more tightly scoped
block win. In practice you'll rarely encounter this yet.
The selector with the highest specificity score wins. This is where most real-world cascade conflicts are resolved. Full details in section 3.
If everything else is equal, the declaration that appears later in the source wins. This is the final tiebreaker — and the reason linking a reset stylesheet before your own CSS is important.
2. !important — What It Actually Does
!important bumps a declaration to a higher origin tier. It does
not make a declaration "infinitely specific" — it moves it to a different
layer of the cascade where the filter restarts. Two !important declarations
from the same origin still compete on specificity, then order.
!important you add makes the next conflict harder to reason about —
you'll need another !important with higher specificity to override it.
Legitimate uses: third-party stylesheet overrides, user accessibility stylesheets,
and utility classes designed to always win (e.g. .hidden { display: none !important; }).
If you're using it frequently to fix bugs, the real problem is a specificity architecture issue.
3. Specificity — The Scoring System
Specificity is a three-column score written as (IDs, Classes, Elements). Each selector contributes points to exactly one column based on the type of selector it contains. Columns are compared left to right — a single ID beats any number of classes, regardless of how many classes are stacked.
#id.class · [attr] · :hover · :nth-child()div · p · ::before · ::afterSpecificity score reference
| Selector | Score (A-B-C) | Notes |
|---|---|---|
| * | 000 | Universal selector contributes nothing |
| p | 001 | One element type |
| p::before | 002 | Pseudo-elements count as element |
| .card | 010 | One class |
| [type="text"] | 010 | Attribute selectors count as class |
| :hover | 010 | Pseudo-classes count as class |
| :is(h1, h2) | 001 | :is() takes the specificity of its most specific argument |
| :where(h1, h2) | 000 | :where() always contributes zero — intentionally low-specificity |
| :not(.active) | 010 | :not() takes the specificity of its argument (.active = class) |
| .card p | 011 | One class + one element |
| nav .card p | 012 | One class + two elements |
| #header | 100 | One ID — beats any number of classes |
| #header .nav a:hover | 121 | 1 ID + 2 classes/pseudoclasses + 1 element |
| style="" | 100 × ∞ | Inline styles have a separate column above IDs — they always beat selectors |
Reading specificity scores — worked examples
Specificity and the :is(), :not(), :has() gotchas
4. Inheritance
Some CSS properties automatically pass their computed value down to child elements
if no other rule sets them. This is inheritance. It's what lets you set
font-family on body and have it apply to every element on
the page without repeating yourself.
Inheritance only kicks in when no other value is specified for the element — it is the last resort after the cascade finds nothing. The cascade always beats inheritance.
font-family
font-size
font-weight
font-style
line-height
letter-spacing
text-align
text-transform
visibility
cursor
list-style
padding
border
background
width / height
display
position
box-shadow
transform
overflow
flex / grid properties
animation
Controlling inheritance — the four keywords
unset over initial for resets.
initial resets to the CSS specification default, which may differ from
what the browser renders by default. color: initial sets colour to black
regardless of the user's system theme. unset is smarter — it inherits
for inheritable properties and uses initial for the rest, giving more predictable
results in most reset scenarios.
Inheritance vs the cascade — order of resolution
5. Computed, Used, and Resolved Values
CSS has multiple stages of value processing. Understanding them clarifies why DevTools sometimes shows a different value than what you wrote:
This compounding is why em for font-size can produce unexpected results
in nested elements — each level multiplies the parent's computed value. Use
rem (root em) to reference the root font-size consistently instead:
6. Practical Strategies
Keep specificity low by default
Use :where() to write zero-specificity base styles
Leverage inheritance instead of repeating values
Debug cascade conflicts with DevTools
In Chrome/Firefox DevTools, select an element and open the Styles panel. You'll see every matching declaration, sorted by cascade priority. Overridden declarations are shown with a strikethrough. Hover over the selector to see its specificity score. This is the fastest way to diagnose "why isn't my CSS applying?"
Chapter Summary
| Concept | Key point |
|---|---|
| Cascade | A five-stage filter: origin → @layer → scope → specificity → order. First stage that produces a clear winner stops. !important changes origin tier, not specificity. |
| Specificity | Three-column score (IDs, Classes, Elements). Compared left to right — 1 ID beats infinite classes. Inline styles are above IDs. !important is above inline styles. |
| :is() / :has() | Take specificity of their most specific argument. :where() always contributes zero — use for intentionally low-specificity base styles. |
| Inheritance | Typographic and text properties inherit by default; box model and layout properties do not. Cascade always beats inheritance. |
| inherit / initial / unset / revert | Keywords to explicitly control inheritance. Prefer unset over initial for resets. all: revert resets every property at once. |
| em vs rem | em is relative to the parent's computed font-size and compounds. rem is always relative to the root — predictable and non-compounding. |
| Specificity strategy | Keep selectors at one-class specificity where possible. Use :where() for base/reset styles. Avoid IDs in CSS. Reserve !important for genuine utility overrides. |
- Specificity scoring: Without a browser, work out the specificity score for:
body main article.feature > p:first-child. Then check your answer in DevTools. - Inheritance experiment: Set
color: hotpinkonbodyandborder: 2px solid hotpinkonbody. Which elements inherit the colour? Which inherit the border? Why? - :where() rewrite: Take this selector —
header nav ul li a— and rewrite it using:where()so it has zero specificity but still targets the same elements. - The !important trap: Write two rules targeting the same element with
!importanton both. Observe which wins and explain why using cascade step 1 and step 4. - em compounding: Set
html { font-size: 16px }, then nest three elements each withfont-size: 1.25em. Calculate the final font-size at the deepest level. Rewrite using rem and confirm the size stays constant.