Skip to content
Chapter 19Lesson 1

How the browser picks a winning rule

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

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

In both cases two rules set the same property on the element, and exactly one of them wins. The browser isn’t confused: it ran an algorithm, picked a winner, and painted. It feels like a mystery only because you’ve been feeding that algorithm inputs since the last chapter, through @layer blocks, ! modifiers, and cn() calls, without ever seeing the algorithm itself.

This lesson shows you the algorithm. By the end, you can take any “why isn’t this style applying?” moment, trace it to the exact step that decided the winner, and fix it, 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 , which picks the value that paints. It runs four steps, and their order is everything:

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

These are not four factors weighed together like a scorecard. They work like a waterfall. The browser gathers every declaration that targets this element and this property, then drops them through the four gates in order. The first gate that leaves a single winner stops the process; every later gate exists only to break ties the gate above it left behind.

So specificity, which you were probably taught is how CSS conflicts get resolved, is gate 3. It only ever judges the conflicts that two earlier gates failed to settle.

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.

That shape reframes how you debug CSS: you are not weighing four things at once, you are asking one question at a time and stopping the moment one of them answers.

For the stack you are building on, one gate matters most. On a Tailwind v4 project, gate 2, the layer, decides almost every real conflict. Custom CSS silently beating a utility is a layer problem nine times out of ten; the other common case is gate 4, source order, when two conflicting utilities slip through without being deduplicated. Specificity wars are rare here, because utilities are nearly all single-class selectors with identical specificity, so gate 3 has nothing to adjudicate.

One note on gate 1. “Origin” means who wrote the rule: the browser, through its user-agent stylesheet (where an unstyled <h1> gets its big bold look), the user, or you, the author. In a web app, author styles are essentially everything, so origin rarely decides anything on its own. What makes gate 1 first is !important, which can invert the normal order, and we will return to it once the layers are in place.

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

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

A cascade layer is a named bucket you sort rules into, declared with @layer. The rule is simple and absolute: a declaration in a later-declared layer beats one in an earlier-declared layer, no matter how specific either selector is. Layer order is settled before specificity is consulted.

You have already generated layers without writing them by hand. The single @import "tailwindcss" line you added to globals.css 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.

theme and utilities are Tailwind’s; you do not author into them. utilities is declared last, which is why utilities win: a utilities rule like text-2xl beats a base rule like Preflight’s h2 reset regardless of specificity, because the layer gate fires first and utilities is the later layer. That answers the opening heading bug, and it is the point of letting a utility override your element baselines per element.

The trap is a rule that falls outside the system.

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

The reflex to adopt permanently: every custom rule names its layer. You author into two of Tailwind’s:

  • @layer base for global, element-level styling: resets and baselines, your h2 defaults, your code and kbd chip styles, your default link underline. Because utilities sits above base, a utility can still override these per element, which is what you want from a default.
  • @layer components for the rare bespoke component that no composition of utilities can express. On a React codebase most “components” are React components holding utility class strings, not CSS classes, so you reach for this far less often than you think.

Here is the bug and its fix side by side. Picture a .btn class 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 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.

That fix is the template for almost every cascade bug on this stack: you put a rule that floated out of the layered system back where it belongs, and the system resumes working on its own.

One last thing, because it fails quietly. Layer declaration order is fixed by the @layer statement at the top of Tailwind’s generated CSS, anchored to where your @import "tailwindcss" sits. Importing a third-party stylesheet in the wrong place relative to it shifts where those external rules land in the cascade, so place 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 often taught as the main event. On this stack it ranks third, and mostly sits idle.

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

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

Compare two selectors slot by slot from the left; the first slot where they differ decides, and a higher number there wins outright regardless of the slots to its right. A utility class scores (0, 0, 1, 0). An #header ID is (0, 1, 0, 0), and since the ID slot sits left of the class slot, one ID beats any number of classes, even (0, 0, 50, 0). An inline style={{ }} is (1, 0, 0, 0), outranking everything built from IDs or classes. The universal selector * is (0, 0, 0, 0), adding 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: specificity only breaks ties within a single layer. Across layers, the layer gate already chose a winner before specificity ran. So on a Tailwind project, specificity earns its keep almost entirely inside @layer base, the one place your own element rules and Preflight’s 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: you would be tuning a gate the conflict already skipped.

One tool in this gate is worth recognizing, because Tailwind uses it constantly: :where(), the specificity-zero wrapper. Anything inside :where(...) contributes exactly zero to the tuple, however complex the selector within. :where(.dark, .dark *) matches dark-mode elements but scores (0, 0, 0, 0). That is the point: a framework can match the elements it needs without inflating specificity, so the utility classes you stack on top still win their ties cleanly. Without it, every utility would be in a specificity fight with the framework’s own rules.

This connects to a line in your globals.css. Last chapter you wrote a dark-mode variant, and the shipped course code uses the :is(.dark *) form. :is() and :where() 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 fine here: the .dark toggle lives on <html>, and the token swap it drives never duels with your utilities. Tailwind’s own docs show the :where(.dark, .dark *) form, the textbook specificity-zero variant. You do not need to act on the distinction, but now you can read either form and know what it scores.

The tuple has one more consequence to resist. Inline style={{ }} scores (1, 0, 0, 0) and beats every class-based rule, 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 in a different syntax, muscling past the cascade instead of fixing why your rule lost. Inline style is legitimate for genuinely dynamic values, like a custom property computed at runtime, but using it to win a static conflict is the warning sign.

Score these four yourself, reading each selector slot by slot. The :where() line is the one people miss.

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 lands in the class 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 .dark inside :where() contributes nothing and only the trailing .btn counts. Miss that 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”

!important is adjudicated at gate 1, before layers, before specificity, before source order. That is what makes it a smell on a Tailwind stack: you are not winning the cascade, you are bypassing it. The rule you are forcing past is usually not in a genuinely unwinnable fight; it is simply in the wrong layer, or in no layer at all.

So the fix for a losing rule is to put it in the right layer, not to force it. Once you see that the rule lost at the layer gate and that a layer move fixes it there, the reason to reach for !important disappears.

There is a subtler cost too. With cascade layers, !important inverts the layer order: normally a later layer wins, but for !important declarations an earlier layer wins. So in a layered codebase it doesn’t just jump the queue, it reverses the queue’s direction, a second and quieter way to produce surprises.

Is !important ever correct? Only in two cases, both outside your own code:

  1. Overriding third-party inline styles you don’t control. A widget that ships with style="..." on 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 here it is the right tool. Tailwind spells it with the ! modifier from last chapter, where bg-white! emits an !important utility.
  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 out of scope for the app you build.

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

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 settles the cases the first three leave tied.

When two declarations reach it dead even, same origin and importance, same layer, same specificity, the browser takes whichever appears later in the generated stylesheet. For utilities this is the common case. px-4 and px-8 are both single-class selectors, (0, 0, 1, 0), both in the utilities layer, so they tie on every earlier gate. The winner comes down to which one Tailwind emitted last.

Here is where it gets surprising.

Almost everyone assumes the last class in the string wins. It does not. The deciding factor is the stylesheet’s emission order, which Tailwind’s build sets, not you. So it is invisible, you cannot see the emission order, and unstable, the build can change it. That makes for a hard bug to trace.

You already own the fix. Last chapter you met cn(), which is clsx composed with tailwind-merge, and its job here is to resolve utility conflicts before they reach the cascade. cn('px-8', 'px-4') returns just 'px-4': tailwind-merge sees both target the same property, drops the loser, and emits one class. With nothing left to tie, gate 4 has nothing to decide. So the reflex for utility conflicts is not to reason about emission order but to run the classes through cn(), which deletes the conflict that emission order would have settled.

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);

The cascade so far has been a model. DevTools lets you read that model on any real element, and it comes down to one repeatable move.

Two panels matter. The Styles panel lists every rule targeting the selected element in cascade order, with the declarations that lost struck through. A line through a declaration means another rule set that property and beat it; the winner is the one that isn’t crossed out. That resolves most “why isn’t this applying?” questions at a glance.

The Computed panel, the authoritative view , shows the final, resolved value of every property. Expand a property and it reveals the trace: which 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 question at the heart of this lesson, “is the winner unlayered, or is it @layer utilities?”, is right there to read. 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.

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 the fix is a layer move, not !important. Three clicks, no guessing.

Take it back to the heading you opened with, <h2 className="text-2xl"> rendering at the wrong size. 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 sits in utilities, the later layer, exactly as designed, and the surprise is that the heading had no large default to begin with, because Preflight stripped it. Or an 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.

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