Skip to content
Chapter 19Lesson 1

How the browser picks a winning rule

The CSS cascade algorithm that decides which rule wins, and why cascade layers settle most conflicts on a Tailwind v4 project.

You write <h2 className="text-2xl"> and the heading renders smaller than you asked for, as if the utility never landed. Or you write a custom .btn class, drop a bg-primary utility on the same element, and the background just doesn’t take.

Here is what is actually happening. Two rules both set the same property on that element, and exactly one of them gets to paint. There is no ambiguity from the browser’s side: it ran an algorithm, picked a winner, and moved on. It feels like a mystery because you have been writing the inputs to that algorithm since the last chapter without ever seeing the algorithm itself. When you write @layer blocks, tack a ! onto a utility, or wrap class strings in cn(), each of those feeds into a decision the browser makes by rules you have not been shown yet.

This lesson shows you the rules. By the end you will be able to take any “why is this style not applying?” moment, trace it to the exact step that decided it, and fix it the way an experienced engineer would, which is almost never by reaching for !important.

When two or more rules set the same property on the same element, the browser resolves the conflict with the cascade , the algorithm that picks, for each property on each element, which value paints. It runs four steps, and the order they run in is what decides everything:

  1. Origin and importance
  2. Cascade layer
  3. Specificity
  4. Source order

It is tempting to read that as four factors the browser weighs together, like a scorecard, but it works more like a waterfall. The browser collects every declaration that targets this element and sets this property, where a declaration is a single property: value pair, then drops them all through the four gates in order. The first gate that produces a single winner stops the process. Every gate after that exists only to break the ties the gate above it left behind.

So if gate 1 already singles out a winner, gates 2, 3, and 4 never run. If gate 1 leaves a tie and gate 2 breaks it, gates 3 and 4 never run. Specificity, the thing you were probably taught is how CSS conflicts get resolved, is gate 3. It only ever sees the conflicts that survived two earlier gates untied.

font-size: 1.5rem font-size: 2rem font-size: 1rem competing declarations on one element 1 Origin & importance author vs. browser; !important flips the order decided here → winner 2 Cascade layer later layer wins; unlayered beats all decided here → winner 3 Specificity more specific selector wins decided here → winner 4 Source order last one in the stylesheet wins decided here → winner
The cascade algorithm every browser runs, top to bottom — it stops at the first gate that leaves a single winner, so the gates below never run.

Hold onto the shape of that picture, because it reframes the entire job of debugging CSS. You are not weighing four things at once. You are asking one question at a time, in order, and stopping the moment one of them answers.

The next part matters most for the stack you are building on. On a Tailwind v4 project, the gate that decides almost every real conflict is gate 2, the layer. The classic symptom, a piece of custom CSS silently beating a utility, is a layer problem nine times out of ten. The other common one is gate 4, source order, when two conflicting utilities slip through without being deduplicated. Specificity wars, the kind that fill Stack Overflow threads, are rare on a Tailwind codebase. Utilities are nearly all single-class selectors with identical specificity, so there is nothing for gate 3 to adjudicate. The rest of the lesson builds on this.

One note on gate 1 before we go deeper. “Origin” means who wrote the rule. The browser ships its own default styles in the user-agent stylesheet , which is where an unstyled <h1> gets its big bold look. The user can supply their own stylesheet. And then there is you, the author. In a SaaS app, author styles are essentially everything: your CSS and Tailwind’s. So origin barely matters on its own. What makes gate 1 the first gate is !important, which can invert the normal pecking order. We will come back to exactly how once the layers are in place, since that is what makes the inversion easy to follow.

Drag the cascade gates into the order the browser runs them — the first one that leaves a single winner stops the process. Drag the items into the correct order, then press Check.

Origin and importance
Cascade layer
Specificity
Source order

Tailwind’s four layers, and the trap of leaving CSS unlayered

Section titled “Tailwind’s four layers, and the trap of leaving CSS unlayered”

Gate 2 matters most on this stack, so let’s open it up.

A cascade layer is a named bucket you sort rules into, declared with @layer. The rule governing layers is simple and absolute: a declaration in a later-declared layer beats a declaration in an earlier-declared one, no matter how specific either selector is. Layer order is decided before specificity is even consulted. Once that sentence clicks, most Tailwind debugging follows from it.

You have already been generating layers without writing them by hand. The single @import "tailwindcss" line you added to globals.css in the last chapter emits four layers, in this declared order:

Unlayered
your stray CSS — sits ABOVE every layer
wins by accident
utilities
Tailwind-managed
text-2xl, px-4, bg-primary, …
components
Tailwind-managed + your component classes
the rare .btn utilities can't express
you author here
base
Preflight + your global element rules
h2, code, kbd, link baselines
you author here
theme
Tailwind-managed
your --color-* and --spacing-* tokens
— Tailwind's four layers, declared in this order —
Tailwind emits four layers; later layers win. Anything you write outside a layer sits on top of all of them — the usual culprit behind a utility that won't apply.

Walk up that stack. theme and utilities are Tailwind’s, and you do not author into them directly. utilities is declared last of the four, which is exactly why utilities are designed to win. A utilities rule like text-2xl beats a base rule like Preflight’s h2 reset regardless of specificity, because the layer gate fired first and utilities is the later layer. That is the answer to the opening heading bug, and it is the whole point of letting a utility override your element baselines on a per-element basis.

So far the system is doing its job. The trap is what happens when a rule falls outside it.

Why does unlayered win? The cascade treats unlayered declarations as if they belong to a layer declared after every named one. In Tailwind v4 specifically, that places them above utilities, so a stray rule, or a third-party CSS file you @import without wrapping, overrides your component-layer and utility-layer styles alike. Nothing in your code says “this should win.” It wins by accident of where it sits.

The reflex that falls out of this is one you should adopt permanently: every custom CSS rule names its layer. Concretely, you author into two of Tailwind’s layers:

  • @layer base for global, element-level styling: resets and element baselines. Your h2 defaults, your code and kbd chip styles, your default link underline. Putting them in base means a utility can still override them per element, because utilities sits above base. That is what you want, a sensible default that any utility can beat.
  • @layer components for the rare bespoke component that utility classes genuinely cannot express. The threshold matters here: reach for it only when no composition of utilities works. On a React codebase, most “components” are React components holding utility class strings, not CSS classes. You will write into components far less often than you think.

Here is the bug and its fix side by side. Picture a .btn class that a teammate styled in CSS, used in a component that also accepts utility overrides from its caller:

@import "tailwindcss";
.btn {
background: var(--color-secondary);
}

The override silently loses. The component renders <button className="btn bg-primary">, expecting the caller’s bg-primary to win. But .btn is unlayered, so it sits above the utilities layer where bg-primary lives. The button paints secondary and the override is ignored.

Read that fix carefully, because it is the template for almost every cascade bug you will hit on this stack. The selector did not change, nothing got more specific, and you did not reach for !important. You took a rule that had floated out of the layered system and put it back where it belongs, and the system resumed working on its own.

One last thing to watch on layers, because it fails quietly. The declaration order of layers is fixed by the @layer statement at the top of Tailwind’s generated CSS, and that order is anchored to where your @import "tailwindcss" sits. If you @import a third-party stylesheet in the wrong place relative to it, you can shift where those external rules land in the cascade. So it is worth placing your imports deliberately.

Sort each rule into the layer it belongs in. Utilities sit above both layers, so anything you want a utility to be able to override goes into a layer — never leave it unlayered. Drag each item into the bucket it belongs to, then press Check.

@layer base Global element baselines
@layer components Bespoke component CSS utilities can't reach
A global h2 { font-size: … } size reset
A default link underline, a { text-decoration: underline }
A kbd keyboard-chip style for inline shortcuts
An element-level ::selection { background: … } color
A multi-element .timeline widget with several nested-element rules
A .card with a decorative ::before pseudo-element

Gate 3 is the one you may have been taught was the main event. Here it takes its actual place: third in line, and on this stack, mostly idle.

Specificity is a four-part tuple, read left to right:

(inline styles, ID selectors, class / attribute / pseudo-class selectors, element / pseudo-element selectors)

You compare two selectors slot by slot from the left. The first slot where they differ decides it, and a higher number there wins outright no matter what the slots to its right say. A single utility class scores (0, 0, 1, 0). An #header ID selector is (0, 1, 0, 0), and because the ID slot sits to the left of the class slot, that one ID beats any number of classes, even (0, 0, 50, 0). An inline style={{ }} attribute is (1, 0, 0, 0), which outranks everything built from IDs or classes. The universal selector * contributes (0, 0, 0, 0), so it adds nothing.

selector
inline
style={…}
ID
#id
class / attr / pseudo-class
.cls [attr] :hover
element / pseudo-element
div ::before
.btn
0
0
1
0
a:hover
0
0
1
1
#cta
0
1
0
0
:where(.dark) 0,0,0,0 .btn
0
0
1
0
#cta — the ID slot sits left of class — this 1 beats both rows above
:where(.dark) .btn — :where(…) adds 0 — only the trailing .btn counts
Compared left to right — the first slot that differs decides, and a higher number there wins no matter what sits to its right. But the browser only reaches this gate after layer order has already had its say.

Here is the demotion stated plainly: specificity only breaks ties within a single layer. Across layers, the layer gate already chose a winner before specificity got a turn. So on a Tailwind project, specificity earns its keep almost entirely inside @layer base, the one place where your own element rules and Preflight’s element rules coexist and might genuinely clash. Between a utility and your custom CSS, the layer gate fired first and specificity never ran. This is why “raise the specificity” is almost always the wrong instinct here: you would be tuning a gate that the conflict already skipped.

One tool in this gate is worth recognizing, because Tailwind uses it constantly: :where(), the specificity-zero wrapper. Anything you put inside :where(...) contributes exactly zero to the tuple, no matter how complex the selector inside it is. :where(.dark, .dark *) matches dark-mode elements but scores (0, 0, 0, 0) for the wrapper itself. That is the entire point. A framework can match the elements it needs without inflating specificity, which means the utility classes you stack on top still win their ties cleanly. If frameworks matched with normal selectors, every utility would be in a specificity fight with the framework’s own rules. :where() defuses that.

This connects to a line already living in your globals.css. Last chapter you wrote a dark-mode variant, and the shipped course code uses the :is(.dark *) form. It is worth knowing how the two compare. :is() and :where() look similar but differ in exactly one way: :where() always scores zero, while :is() takes the specificity of its most specific argument. So :is(.dark *) contributes the specificity of .dark, a single class. That is completely fine for the dark variant. The .dark toggle lives high up on the <html> element, and the token swap it drives never gets into a specificity duel with your utilities. Tailwind’s own docs happen to show the :where(.dark, .dark *) form, which is the textbook specificity-zero variant. The distinction is not something you need to act on, but now you can read either form and know exactly what it scores.

The tuple has one more consequence worth naming, and it is one to resist. Inline style={{ }} scores (1, 0, 0, 0) and therefore beats every class-based rule you could write. So when a style won’t apply, it is tempting to set it inline and force the win. Avoid that. It is the same anti-pattern as !important, spelled with a different syntax: you would be muscling past the cascade instead of fixing why your rule lost. Inline style does have a legitimate home, for genuinely dynamic values like a CSS custom property computed at runtime, but that is a different chapter’s topic. Using it to win a static conflict is the warning sign.

Score these four for yourself, reading each selector slot by slot to pick its tuple. The :where() line is the one that catches people.

Pick the specificity tuple — (inline, ID, class/attr/pseudo-class, element/pseudo-element) — for each selector. Remember: a pseudo-class counts in the class slot, and anything inside :where() scores zero. Pick the right option from each dropdown, then press Check.

.btn ___
#cta ___
a:hover ___
:where(.dark) .btn ___

The last two are the traps. a:hover is (0, 0, 1, 1): the :hover pseudo-class lands in the class slot, not the element slot, so it counts as one class and one element, not two elements. And :where(.dark) .btn scores (0, 0, 1, 0), the same as a bare .btn, because the .dark inside :where() contributes nothing and only the trailing .btn counts. Miss the :where() rule and you’d guess (0, 0, 2, 0) and expect it to win a tie it actually draws.

!important is a smell, and the two times it isn’t

Section titled “!important is a smell, and the two times it isn’t”

This brings us back to gate 1, and to the reflex this section is really about.

!important is adjudicated at the very first gate, before layers, before specificity, before source order. That is precisely what makes it a bug smell on a 2026 stack. When you write !important, you are not winning the cascade, you are bypassing it. You switch off the predictable four-gate machine and assert a winner by force. On a Tailwind project, the thing you are usually forcing past is not some genuinely unwinnable fight; it is a rule that is simply in the wrong layer, or in no layer at all.

It helps to follow the chain of cause back to its start. !important is what people reach for when they don’t understand the layer gate. The custom rule loses, they don’t know why it loses, so they hit it with the biggest hammer available. Once you understand that the rule lost at gate 2 and that moving it to the right layer fixes it at gate 2, the reason to reach for !important disappears. The fix for a losing rule is to put it in the right layer, not to force it.

There is also a subtler reason !important makes a layered codebase harder to reason about. With cascade layers, !important inverts the layer order. Normally a later layer beats an earlier one, but for !important declarations an earlier layer beats a later one. So in a layered codebase, !important doesn’t just jump the queue, it reverses the queue’s direction, which is a second and quieter way for it to produce surprises. This inversion is real but rare, and you do not want to be the one who introduced it.

So is !important ever correct? Yes, but only narrowly, so that “never” stays an honest rule rather than one this course quietly breaks:

  1. Overriding third-party inline styles you don’t control. A widget that ships with style="..." baked onto its root element scores (1, 0, 0, 0), beats every class you can write, and you can’t edit its markup. An !important declaration outranks even inline styles, so this is the one place it is the right tool. Tailwind gives you the idiomatic spelling: the ! modifier you met last chapter, where bg-white! emits an !important utility. This is the SaaS-relevant carve-out, because third-party embeds are a fact of life.
  2. User-stylesheet accessibility overrides. A user’s own stylesheet forcing high contrast or larger text uses !important to override author styles. That is the user’s CSS, not yours, and it is out of scope for the app you build, named here only so the picture is complete.

Both carve-outs share a shape: !important is correct only when the thing you are overriding is outside your codebase and you genuinely cannot reach it any other way. The moment the rule you are fighting is your own, !important is the wrong answer and a layer move is the right one.

A teammate’s custom .btn keeps painting its own background, even though the component is rendered with a bg-primary utility that’s meant to override it. Both rules are valid CSS and neither carries !important. What does an experienced engineer reach for first?

Tack !important onto the .btn background so it definitely wins.
Rewrite the selector as #app .btn so it outweighs the utility.
Wrap the .btn rule in @layer components.
Put bg-primary last in the className string so it lands later.

Source order, and why cn() makes it trustworthy

Section titled “Source order, and why cn() makes it trustworthy”

Gate 4 is the final tiebreaker, and the one your existing tooling already neutralizes for you.

When two declarations survive all three earlier gates dead even, meaning same origin and importance, same layer, same specificity, the browser falls back to source order: whichever declaration appears later in the generated stylesheet wins. For utilities this is the common case, not an edge case. px-4 and px-8 are both single-class selectors, (0, 0, 1, 0), both living in the utilities layer. They tie on every earlier gate. So the winner comes down entirely to which one Tailwind happened to emit last in the compiled CSS.

Here is the trap that follows, the one that genuinely surprises people:

Read that twice, because it inverts the intuition almost everyone arrives with. You would swear the last class in the string wins; it does not. The string order is irrelevant, and the stylesheet order is what counts, which Tailwind’s build determines rather than you. So the deciding factor is invisible, because you can’t see the emission order, and it can shift, because the build can change. That is a hard kind of bug to track down.

You already own the fix. Last chapter you met cn(), which is clsx composed with tailwind-merge. For this purpose, its whole job is to resolve utility conflicts before they ever reach the cascade. cn('px-8', 'px-4') returns just 'px-4', because tailwind-merge knows px-8 and px-4 target the same property, so it drops the loser and emits a single surviving class. Only one px-* reaches the DOM. With nothing to tie, the source-order gate has nothing to adjudicate, and the question that caused the bug stops existing.

So the reflex for utility conflicts is not to reason about source order at all. You run them through cn() and the ambiguity is gone. Rather than memorizing emission order, you delete the conflict that would have depended on it. How cn() and tailwind-merge decide which class survives is the previous chapter’s territory. Here, all that matters is that it makes gate 4 a non-issue.

The element below was rendered with class="px-8 px-4" — two padding utilities, no cn(), written straight into the string. The root font-size is the browser default, 16px. Predict the one line this logs. Predict what this program prints, then press Check.

// rendered markup: <div id="box" class="px-8 px-4">…</div>
const box = document.getElementById('box');
console.log(getComputedStyle(box).paddingLeft);

Everything so far is the model. DevTools is the instrument that lets you read the model on any real element, and it comes down to one repeatable move rather than a feature tour. Learn it once and you will use it for the rest of your career.

Two panels matter. The Styles panel shows every rule targeting the selected element, listed in cascade order, with the declarations that lost struck through. A line through a declaration is the browser telling you, in the plainest possible terms, “this rule set this property and a different rule beat it.” The winner is the one that is not crossed out. That alone resolves a huge fraction of “why isn’t this applying?” questions at a glance.

The Computed panel, the authoritative view , is where the durable skill lives. It shows the final, resolved value of every property. When you expand a property, it reveals the trace: which specific rule supplied the winning value, and in modern Chrome and Firefox, which cascade layer that rule came from. The Cascade Layers indicator labels each rule with its layer, so the distinction at the heart of this lesson, “is the winner unlayered, or is it @layer utilities?”, is right there to read. It shipped in Chrome 99 and Firefox 97, so it has been universal since long before 2026. If a declaration carries !important, the panel flags that too.

Styles
Computed
Layout
Event Listeners
Filter Show all
color : oklch(0.21 0.03 264)
font-size : 16px
16px .heading unlayered globals.css:8
24px .text-2xl @layer utilities index.css
display : block
line-height : 24px
1 Final value — what actually paints: 16px, not the text-2xl you asked for.
2 Layer label — names the gate-2 winner. Here it reads unlayered: the bug.
3 Struck through — this declaration lost. The utility was beaten by the unlayered rule above it.
The Computed panel, font-size expanded into its trace: the struck-through rule lost, the rule above it won — and the amber layer label says why. A winner tagged unlayered is the bug; the fix is a layer move, not !important.

Here is the move, as a procedure to keep. Whenever a style won’t apply:

  1. Select the element and open the Computed panel.
  2. Expand the misbehaving property to see its trace.
  3. Read which rule won and which layer it sits in.

If the winning rule is unlayered, or sits in a later layer than you expected, you have found your bug, and you already know the fix is a layer move, not !important. That is three clicks, with no guessing.

Take it back to the heading you opened with, <h2 className="text-2xl"> rendering at the wrong size. You now have the whole loop. The symptom is a wrong font size, so you select the <h2>, open Computed, expand font-size, and read the trace. One of two things is true. Either text-2xl is winning, because it’s in utilities, the later layer, exactly as designed, and the size you’re seeing really is text-2xl. In that case the cascade is working correctly, and the surprise is that the heading had no large default to begin with, because Preflight stripped it. A later lesson in this chapter takes that up. Or some unlayered rule of your own is sitting above utilities and stealing the win, in which case the deciding gate is layer and the fix is to move that rule into the layer it belongs in. Either way, the trace told you which gate decided, and you reached for a layer, not for !important.

That is the mental model to walk away with. The cascade is a four-gate waterfall: origin and importance, then layer, then specificity, then source order. The browser stops at the first gate that leaves a single winner. On a Tailwind v4 app the layer gate decides almost everything, so the experienced engineer’s fix for a losing rule is to name its layer, not to escalate specificity or drop an !important. When you can’t reason it out by eye, the Computed panel’s trace tells you which gate decided in three clicks. The whole thing is mechanical, and once you can see the gates, nothing about it is a mystery anymore.

The `.heading` rule is winning by accident — it's unlayered, so it sits above every Tailwind layer, including `utilities` where `text-2xl` lives. Wrap it in `@layer base { … }` so it drops below the utilities layer and `text-2xl` can override it again. Watch the heading jump to its larger size the moment you layer it.

Preview LIVE