How CSS Works
Your Styles Don't Apply the Way You Think
You write color: red and the text turns red. Feels like magic, right? But between your declaration and the final pixel color, the browser runs a multi-stage pipeline: parsing, tokenizing, building a tree, resolving conflicts across multiple origins, calculating specificity, handling inheritance, converting relative values to absolute ones, and finally resolving layout-dependent values during rendering.
Here's the thing -- understanding this pipeline is the difference between debugging CSS by trial and error and debugging it by knowing which stage went wrong.
Think of CSS processing as a courtroom trial. Multiple witnesses (stylesheets) present conflicting testimony (declarations). The judge (cascade algorithm) has strict rules for which testimony wins: first check the court (origin), then the credibility (specificity), then the order they spoke (source order). The verdict (computed value) is final — but the sentence (used value) depends on context the judge didn't have at trial time.
Stage 1: Parsing CSS Into the CSSOM
So what actually happens when the browser encounters a <link> tag or <style> block? It hands the raw bytes to the CSS parser. The parser:
- Decodes bytes into characters using the specified encoding (UTF-8 by default)
- Tokenizes characters into CSS tokens — identifiers, strings, numbers, delimiters
- Parses tokens into a tree of rules, selectors, and declarations
- Builds the CSSOM (CSS Object Model) — a tree structure mirroring the DOM
/* This CSS text... */
body {
font-size: 16px;
color: #333;
}
.header {
font-size: 2rem;
}
/* ...becomes a CSSOM tree:
StyleSheet
├── CSSStyleRule (selector: "body")
│ ├── font-size: 16px
│ └── color: #333
└── CSSStyleRule (selector: ".header")
└── font-size: 2rem
*/
CSS parsing is forgiving. Unlike JavaScript, an invalid CSS declaration doesn't crash the parser. The browser silently discards what it can't understand and keeps going:
.box {
color: red;
font-size: ????; /* Invalid — browser ignores this line */
background: blue; /* This still applies */
}
This error recovery behavior is why CSS feature detection works — old browsers ignore display: grid because they can't parse it, and the fallback display: block still applies.
CSS parsing is render-blocking. The browser won't paint anything until it has built the CSSOM because it needs styles to compute the render tree. A massive unoptimized CSS file delays your first paint — even if most rules don't apply to the current page.
Stage 2: The Cascade Algorithm
This is where it gets interesting. When multiple declarations target the same property on the same element, the cascade resolves the conflict. The algorithm checks — in order:
1. Origin and Importance
CSS has three origins, checked in this priority order (highest to lowest for normal declarations):
| Priority | Origin | Example |
|---|---|---|
| 1 | User agent !important | Browser default !important rules |
| 2 | User !important | User stylesheet !important |
| 3 | Author !important | Your !important declarations |
| 4 | Animation | @keyframes values during animation |
| 5 | Author normal | Your regular stylesheets |
| 6 | User normal | User preferences |
| 7 | User agent normal | Browser defaults (<h1> is bold) |
Now here's the part that surprises everyone: for !important declarations, the priority order reverses. User agent !important beats author !important. This protects accessibility — a user's high-contrast stylesheet can override your design.
2. Specificity
If two declarations have the same origin and importance, specificity breaks the tie. Specificity is calculated as a three-component tuple (ID, CLASS, ELEMENT):
/* (0, 0, 1) — one element selector */
p { color: blue; }
/* (0, 1, 0) — one class selector */
.text { color: green; }
/* (1, 0, 0) — one ID selector */
#intro { color: red; }
/* (0, 1, 1) — one class + one element */
p.text { color: purple; }
/* (1, 1, 1) — one ID + one class + one element */
p#intro.text { color: orange; }
Specificity is not base-10
Specificity is often taught as a single number (e.g., "an ID is worth 100 points"). This is misleading. Specificity is compared component by component, left to right. A selector with 11 classes (0, 11, 0) does NOT beat one ID (1, 0, 0). The ID column wins before classes are even considered.
3. Source Order
If origin and specificity are equal, the declaration that appears last in source order wins.
.box { color: red; }
.box { color: blue; } /* Wins — same specificity, later source order */
Stage 3: Computed, Used, and Resolved Values
You'd think we're done after the cascade picks a winner. Not quite. The value goes through several more transformations:
Specified Value
The value declared in CSS after cascade resolution, or the inherited/initial value if no declaration exists.
Computed Value
Relative values resolved as far as possible without layout:
emconverted relative to parentfont-sizeremconverted relative to rootfont-sizeinheritresolved to parent's computed value- Relative URLs made absolute
/* If root font-size is 16px and parent font-size is 20px */
.child {
font-size: 1.5em; /* Computed: 30px (1.5 × 20px parent) */
width: 50%; /* Computed: 50% (can't resolve without layout) */
margin: 2rem; /* Computed: 32px (2 × 16px root) */
}
Used Value
The value after layout. Percentages that depend on containing block dimensions are resolved here:
.child {
width: 50%; /* Used: 400px (if containing block is 800px wide) */
}
Actual Value
The final value after rounding and device constraints. If the device can't render 14.7px borders, the actual value might be 15px.
When you inspect an element in DevTools, the "Computed" tab shows resolved values — a mix of computed and used values designed to be useful for debugging. For layout-dependent properties like width, you see the used value. For things like color, you see the computed value.
Production Scenario: "Why Doesn't My Override Work?"
/* library.css (loaded first) */
.btn.btn-primary { /* Specificity: (0, 2, 0) */
background: #007bff;
}
/* app.css (loaded second) */
.custom-btn { /* Specificity: (0, 1, 0) */
background: #ff6600; /* Doesn't win — lower specificity */
}
The fix is not !important. Resist that urge. The real fix is understanding the cascade:
/* Option 1: Match or exceed specificity */
.btn.custom-btn {
background: #ff6600; /* (0, 2, 0) — ties, but later source order wins */
}
/* Option 2: Use a cascade layer (modern approach) */
@layer library, app;
@layer library {
.btn.btn-primary { background: #007bff; }
}
@layer app {
.custom-btn { background: #ff6600; } /* Layer order wins over specificity */
}
| What developers do | What they should do |
|---|---|
| Thinking specificity is a single number (100 for ID, 10 for class, 1 for element) 11 classes (0,11,0) still loses to 1 ID (1,0,0) | Specificity is a three-component tuple compared column by column — no amount of classes can outweigh one ID |
| Using !important to win specificity battles !important creates a parallel specificity war that's even harder to override | Restructure selectors, use cascade layers, or increase specificity minimally |
| Thinking CSS loads like JavaScript — non-blocking Without styles, the browser can't compute the render tree | CSS is render-blocking — the browser delays painting until the CSSOM is built |
| Assuming computed values are the same as used values width: 50% can't become pixels until the containing block size is known | Computed values resolve what's possible without layout. Used values resolve after layout. |
- 1CSS is render-blocking — large stylesheets delay first paint
- 2The cascade resolves conflicts by: origin > specificity > source order
- 3Specificity is a three-component tuple (ID, CLASS, ELEMENT) — never a single number
- 4Computed values resolve relative units without layout. Used values resolve after layout.
- 5CSS parsing is forgiving — invalid declarations are silently ignored, enabling progressive enhancement