Medium wave · 540–1600 kHz79 stn
1528kHz

Unlayered CSS Wins Every Fight

A towering stack of translucent brass-framed glass strata in a dark steampunk vault, each layer etched with faint glowing circuit patterns, while a single small unframed copper plate floats above the entire stack blazing with fierce neon amber light that overpowers every glowing layer beneath it, fog drifting across polished floors

You add text-purple-600 to a link. The link stays blue. You open DevTools and find the rule that wins: a plain a selector setting color, sitting in your global stylesheet. A bare element selector, specificity 0,0,1, beating a class at 0,1,0.

That is not a browser bug and it is not a Tailwind bug. It is the cascade doing exactly what it was told. Somewhere between Tailwind v3 and v4, your utilities moved into a cascade layer, and your global CSS did not.

Layers are compared before specificity. Once you understand that one sentence, a whole category of “why is this style not applying” tickets goes away.


Where layers sit in the cascade

When two declarations target the same property on the same element, the browser sorts them by a fixed sequence of questions. Simplified for author styles:

  1. Is either one !important?
  2. Which cascade layer does each belong to?
  3. Which has higher specificity?
  4. Which comes later in source order?

Specificity is step three. It only breaks ties between declarations in the same layer. A rule in a later layer beats a rule in an earlier layer even if the earlier one is #app main article .content p a.

And here is the part that bites: styles not in any layer are treated as one final, implicit layer. They come after everything layered. So for normal declarations, unlayered CSS beats all layered CSS, always, regardless of selector weight.

@layer utilities {
  .text-purple-600 {
    color: var(--color-purple-600);
  }
}

/* Unlayered. Specificity 0,0,1. Still wins. */
a {
  color: var(--link);
}

Every evergreen browser has shipped this since early 2022. It is not an experiment. It is how your stylesheet is being resolved right now.


Why Tailwind v4 made this everyone’s problem

Tailwind v3 had an @layer directive, but it was a Tailwind feature, not a CSS one. The build hoisted your rules into the right bucket and emitted plain, unlayered CSS. Utilities won fights because they came last in source order and usually had enough specificity to beat element selectors.

Tailwind v4 emits real, native cascade layers. The single import expands to roughly this:

@layer theme, base, components, utilities;

@import 'tailwindcss/theme.css' layer(theme);
@import 'tailwindcss/preflight.css' layer(base);
@import 'tailwindcss/utilities.css' layer(utilities);

That is a better design. Preflight can no longer accidentally beat your components, and utilities reliably beat components without a specificity arms race.

But it changes the contract for everything you wrote around Tailwind. Any rule in globals.css that is not wrapped in a layer now sits above utilities. In v3, your global a rule lost to .text-purple-600 on specificity. In v4, it wins on layer order, and specificity never gets consulted.

The migration does not fail. It builds cleanly and renders slightly wrong in a few places that nobody notices until a designer does.


The usual offenders

Unlayered CSS gets into a Tailwind project in predictable ways:

  • Global element styles. a, h1, button, img rules written straight into the stylesheet after the Tailwind import.
  • Imported partials. A second file pulled in with a plain @import './thing.css'; lands unlayered.
  • Third-party stylesheets. Search UIs, syntax themes, embed widgets, and date pickers ship plain CSS. Every rule in them now outranks your utilities.
  • Component-scoped styles. Astro <style> blocks and CSS Modules emit unlayered rules by default. A scoped margin reset on p will beat mb-4 on that paragraph.

That last one surprises people most, because scoping feels like it should make a style weaker. Scoping limits where a rule applies. It does nothing to how strongly it applies.


Fix: give every rule a layer

The rule is simple. If you are using Tailwind v4, nothing you write should be unlayered unless you deliberately want it to beat utilities.

Your own global styles

Element defaults belong in base. Reusable classes belong in components. Custom utilities use @utility so they get variants and sort correctly.

@import 'tailwindcss';

@layer base {
  a {
    color: var(--link);
  }
}

@layer components {
  .card {
    border-radius: var(--radius-lg);
    padding: 1.5rem;
  }
}

@utility text-balance-safe {
  text-wrap: balance;
}

Now text-purple-600 on a link beats the base a rule, because utilities comes after base. That is the order you meant.

Third-party CSS

Import it into a layer of its own, and declare that layer early so it sits beneath your styles:

@layer theme, base, vendor, components, utilities;

@import 'tailwindcss';
@import './vendor/search-ui.css' layer(vendor);
@import './vendor/code-theme.css' layer(vendor);

The first @layer statement fixes the order. Layers are ordered by first appearance, so declaring the full list up front means nothing later can reshuffle it. The vendor styles still apply, but any utility or component you write wins without a fight.

If your bundler inlines imports, check the output once. Vite and Lightning CSS preserve the layer() wrapper, but a hand-rolled PostCSS chain might not.

Component-scoped styles

Wrap them too. It costs one line:

<style>
  @layer components {
    p {
      margin: 0;
    }
  }
</style>

Now a utility class on that paragraph wins, which is almost always what the person adding the utility expected.


The !important inversion

Layers flip for important declarations. For !important rules, earlier layers beat later ones, and unlayered important styles lose to layered important styles.

@layer base, utilities;

@layer base {
  .sr-only {
    position: absolute !important;
  }
}

@layer utilities {
  .static {
    position: static !important;
  }
}

Here base wins. That sounds backwards until you see the intent: a low layer like a reset or an accessibility primitive can use !important to say “this must hold,” and nothing above it can override that by accident.

The practical consequence is that !important is no longer a universal escape hatch. Reaching for it in a high layer to win a fight you are losing is the wrong move. The fight is about layer order, so fix the layer.


Debugging in two minutes

When a style does not apply, stop counting selectors. Ask which layer each competing rule is in.

Chrome and Firefox DevTools both show the layer name next to each rule in the Styles panel. Chrome also has a layer view that lists the full order for the page. If the winning rule shows no layer at all, you have found your bug.

A quick audit for a project mid-migration:

# List top-level rules in your stylesheets that are not inside @layer or @utility
grep -nE '^[a-z.#\[*:][^@]*\{' src/styles/*.css

It is crude. It catches the global element rules and stray classes that are most likely to be shadowing utilities, which is most of what you need.

You can also use revert-layer to test a hypothesis in DevTools. Setting color: revert-layer on the winning rule rolls the value back to whatever the previous layer said. If the utility suddenly applies, layer order was the problem.


The takeaway

Specificity used to be the only lever, so we built conventions around it: BEM to keep it flat, !important to break ties, utility-first to make it irrelevant. Cascade layers replace all of that with an explicit, declared order that the browser respects before it ever counts a class.

The price is that anything left outside a layer is ranked above everything inside one. In a Tailwind v4 project, that means every stray global rule, every imported partial, and every scoped component style is quietly overriding your utilities.

Declare your layer order once at the top of the stylesheet. Put every rule you write into one of those layers. Leave nothing unlayered unless you want it to win every fight.