โ† All posts
CSSCascade

Why @layer makes your CSS lose to Tailwind

August 2, 2026 ยท 9 min read

There is a rule in CSS Cascade Level 5 that inverts the instinct most of us have built over a decade of writing stylesheets: for any given property, an un-layered author declaration beats every layered one โ€” regardless of selector specificity, and regardless of which file loaded last. A bare button { } in someone's reset defeats .btn inside @layer components. We learned this the expensive way, by shipping a stylesheet that was correct, polite, and comprehensively overruled on every Tailwind v3 page it landed on.

The rule

The cascade sorts declarations by origin and importance first, then by cascade layer, then by specificity, then by source order. "Layer beats specificity" is the part people remember. The part that bites is where un-layered styles sit in that ordering: for normal (non-!important) author declarations, un-layered styles form the highest-priority band of the author origin. Every layer you declare sits below them, in the order you declared it.

So this is a fight you lose, and specificity never enters into it. Here is the smallest thing that reproduces it. The first block is a reduction of the parts of Tailwind v3's preflight that matter; the second is a framework doing the polite, recommended thing:

/* host page โ€” un-layered, loaded FIRST */
*, ::before, ::after { border-width: 0; border-style: solid; }
button { background-color: transparent; background-image: none;
         padding: 0; font-weight: inherit; color: inherit; }
ul { list-style: none; margin: 0; padding: 0; }
h2 { font-size: inherit; font-weight: inherit; }

/* framework โ€” fully layered, loaded SECOND */
@layer reset, base, layout, components, utilities;
@layer components {
  .btn      { padding: 8px 24px; font-weight: 600;
              background-color: rgb(40,50,60); border: 1px solid rgb(70,80,90); }
  .prose ul { list-style: disc; padding-left: 24px; }
  .prose h2 { font-size: 18px; font-weight: 700; }
}

Computed styles for <button class="btn"> and the prose elements, measured in Chrome:

Every single declaration in the layered block lost. The framework stylesheet is still there, still parsed, still matching โ€” it just never wins a property the host also sets.

Why this is specifically a framework author's problem

Emitting everything inside @layer is normally the right call, and it is the behaviour we document and recommend. Farvist declares its order once, in scss/base/_layers.scss:

@layer reset, base, layout, components, utilities;

That buys two things. Utilities beat components without !important, purely by layer order. And any CSS the consumer writes outside a layer beats all of Farvist โ€” no specificity war, no !important arms race. "Write .btn { border-radius: 0 } in your own sheet and you win" is a genuinely nice property to be able to promise.

It becomes a liability the moment your stylesheet is an add-on rather than the page's primary framework. dist/farvist-ai.css is exactly that: the AI-interface kit โ€” chat thread, prompt composer, rendered-markdown prose, tool-call cards, diffs, snippets, the โŒ˜K palette โ€” as a drop-in stylesheet for sites already running Tailwind, Bootstrap or their own CSS, 6.4 KB gzip minified. It deliberately ships no reset, no grid, no utilities. The host page keeps its own. Being fully layered is the whole social contract of that file.

The contract holds beautifully against Bootstrap, whose component CSS is un-layered class selectors: Bootstrap's .btn restyles the kit's send button, which is what a host expects. It also holds against Tailwind v4, whose preflight lives inside @layer base โ€” layered, so it sorts below the kit's components layer and loses. The contract collapses against Tailwind v3.

Tailwind v3's preflight is un-layered

Preflight in v3 is emitted outside any layer. Against a layered add-on, that makes it undefeatable for every property it touches, and it touches a lot of them. Verified against real [email protected] output, the ones that mattered to us:

That last one is worth separating from the others, because it is not cosmetic. The kit's .prose scope exists to render model output โ€” markdown that a language model just produced. A bulleted list with no bullets and no indentation is a run of sentences; an <h2> at body weight and body size is not a heading. The document structure the model expressed is still in the DOM, but it is gone from the rendered page. That is information loss, and it is the reason we treated this as a correctness bug rather than a styling nit.

Loading your stylesheet later does not help

The instinct on seeing a cascade conflict is to load your file after theirs. In the repro above the layered sheet is second, and it still loses everything. Source order is the last tiebreaker the cascade consults; layer position is settled several steps earlier. Nothing you do with <link> ordering, bundler output order, or injection timing changes a layered-versus-un-layered comparison. Raising specificity does not help either โ€” .prose .message .btn inside a layer still loses to button outside one.

There are exactly two ways out: leave the layer, or use !important. And !important is stranger than you would guess โ€” for important declarations, layer order reverses, and un-layered important sits at the bottom. We measured it: an !important declaration inside @layer components beat an !important declaration outside all layers, even though the un-layered rule came first in the file and had identical specificity. Correct per spec, and a fine piece of trivia, but shipping !important from a framework is exactly the rudeness layers were invented to avoid. So: leave the layer.

The fix: re-assert exactly what preflight took

dist/farvist-ai-compat.css is an un-layered stylesheet, loaded after the Tailwind build, that re-states the specific properties preflight zeroes โ€” and nothing else. Real lines from the shipped file, wrapped here for readability:

.btn{padding:0.5rem 1.5rem;font-size:1rem;font-weight:600;line-height:1.6;
     color:var(--fv-btn-color);background-color:var(--fv-btn-bg);
     border:1px solid var(--fv-btn-border)}
.btn-gradient-primary{color:var(--fv-btn-color);background-image:var(--fv-gradient-primary)}
.message-bubble{border:1px solid var(--fv-glass-border)}
.tool-call{border:1px solid var(--fv-glass-border);border-left:3px solid var(--fv-border-color)}
.prose ul{margin-bottom:0.75rem;padding-left:1.5rem;list-style:disc}
.prose h2{font-size:1.125rem;margin-top:1rem;margin-bottom:0.5rem;font-weight:700}

Every selector carries exactly one class and nothing heavier: 26 of the 46 rules are a bare class at (0,1,0), and the rest add a type selector where the kit's own scoping needs one โ€” .prose ul at (0,1,1), .prose li + li at (0,1,2). That single class is the whole mechanism, because specificity compares class count before element count. Once you are back in the un-layered band, ordinary rules apply again, and they produce a clean three-way ranking:

So a Tailwind v3 host gets its kit back without losing the ability to restyle it. Adding those class rules to the repro restores the button exactly: border-top-width 1px, padding-left 24px, font-weight 600, background-color rgb(40, 50, 60); the list gets disc and its indent back.

Note what this file gives up, deliberately. Being un-layered, it no longer defers to the host for the properties it names. We accepted that trade for a bounded set of properties on a bounded set of class names, and shipped it as a separate opt-in file so that hosts which do not need it never pay for it. Tailwind v4 and Bootstrap pages should not load it.

Generate the compat sheet; do not hand-write it

A hand-maintained compatibility sheet is a guaranteed future bug: someone changes a border colour token or a button's padding in the Sass, the main build updates, and the compat file quietly starts re-asserting last quarter's values over the top of the current ones. It would fail in the worst possible way โ€” silently, and only on the hosts that need it most.

So dist/farvist-ai-compat.css is generated. scripts/gen-ai-compat.mjs parses the compiled, autoprefixed dist/farvist-ai.css into a selector โ†’ declarations map, then walks a spec that lists, per selector, which properties preflight is known to damage:

const CONTROL = ['padding', 'font-size', 'font-weight', 'line-height',
                 'color', 'background-color', 'border'];
const SPEC = [
  ['.btn', CONTROL],
  ['.btn-gradient-primary', ['color', 'background-image']],
  ['.message-bubble', ['border']],
  ['.tool-call', ['border', 'border-left']],
  ['.prose ul', ['margin-bottom', 'padding-left', 'list-style']],
  // โ€ฆ50 entries in total
];

Every emitted value is read out of the build. There is no second source of truth, so drift is not merely discouraged, it is unrepresentable. The 50 spec entries collapse to 46 unique selectors in the output โ€” several selectors appear more than once in the spec, grouped by the reason they need rescuing.

The interesting design decision is in the error handling, and it is a two-tier rule. Property names are treated as candidates: if the kit never declares font-weight on a given selector, preflight cannot take it away, so a missing property is silently skipped. But a missing selector, or a selector for which not one listed property is found, means the build has moved out from under the spec โ€” and that throws:

if (missing.length) {
  throw new Error(`gen-ai-compat: spec entries not found in dist/farvist-ai.css โ€” the build changed; update the SPEC:\n  ${missing.join('\n  ')}`);
}

Rename .tool-call, drop .suggestion, move a border onto a different element, and the build stops with the list of stale entries. The failure mode we care about is a compat file that looks fine and protects nothing; this converts that into a loud build error. The result is 3.7 KB raw, 0.95 KB gzip, and it has its own budget in scripts/check-size.mjs (2 KB gzip) alongside the three main builds, so it cannot balloon unnoticed.

The general rule

None of this is about Farvist, or about Tailwind. If you ship CSS that lands on pages you do not control โ€” a framework, a design system consumed by other teams, an embeddable widget โ€” here is the procedure:

What this does not fix

The compat sheet restores an enumerated list of properties on an enumerated list of selectors. If a host has some other un-layered global rule โ€” a project-wide font-family override, an opinionated * { transition: none } โ€” that rule still wins for its properties, and nothing in the compat file addresses it. This is a targeted patch for one known reset, not a general shield.

It is also not a substitute for the other things an add-on stylesheet has to get right. The kit still assumes box-sizing: border-box (Tailwind and Bootstrap both set it globally; a bare page does not), and it is rem-sized against a browser-default 16px root, so a host using the html { font-size: 62.5% } pattern renders the whole kit at roughly 62% scale. Cascade layers have nothing to say about either.

And we would not recommend this pattern for a primary framework. If your stylesheet is the page's CSS, un-layered re-assertions just make you harder to override for no benefit. The technique earns its keep in exactly one situation: you are a guest on someone else's page, and the host's reset is destroying information rather than restyling it.

The takeaway

We shipped a fully layered stylesheet believing that "layered" meant "well-behaved". It does โ€” but well-behaved is not the same as robust. The moment your CSS shares a page with an un-layered reset, your carefully ordered layers are all sitting below it, and no selector you can write inside them will change that. Test against the real resets your users actually run, because the failure is silent and your own test page will never show it.

Everything described here is in the repo: the generator, the generated compat stylesheet, and the build notes in the docs. Farvist is MIT-licensed and free.


โ† All posts Read the docs โ†’