Skip to content
All writing

MUI vs Tailwind: the cascade-layers war story

5 min read

css
react
nextjs
debugging

The admin console's every input lost its outline. Every button flattened to plain text. A <Select> printed its raw value on top of its own label. Nothing threw. The server-rendered pages looked perfect — the breakage lived only on the pages behind the login, which is exactly where nobody looks until they're using them.

Two CSS systems share the web app: MUI (component styles injected at runtime by Emotion) and Tailwind v4 (utility classes, plus its "preflight" reset). Their coexistence is a solved problem — MUI documents it — and the solution is CSS cascade layers. This is the story of how a working layer setup got broken twice, each time by a single plausible-looking line, and what the cascade actually does when you get the order wrong.

TL;DR — MUI's official Tailwind v4 integration puts every Emotion style into a mui cascade layer; the global stylesheet declares the order theme, base, mui, components, utilities, so Tailwind utilities (later layers) reliably beat MUI. Two ways to break it, both shipped once: StyledEngineProvider injectFirst shadows the App Router's cache with an unlayered one, and Tailwind silently stops winning (a md:hidden that never hides). prepend: true on the cache provider inserts Emotion's @layer mui {} at the front of <head> — and since a layer's priority is fixed by where it's first declared, mui becomes the lowest layer and Tailwind's preflight outranks every component style: border: 0 solid erases input outlines, background-color: transparent flattens buttons, opacity: 1 un-hides the inputs MUI keeps invisible. It hides because only client-inserted styles are affected — SSR markup looks fine.

(Part 38 of Building CannyCart, a voice-first shopping app I'm building in public. Self-contained — no earlier context needed.)

How the coexistence is supposed to work

CSS cascade layers (@layer) let you declare that entire groups of rules rank below other groups, regardless of specificity or source order within them. Tailwind v4 is built on them: its reset lives in a base layer, utilities in utilities. MUI's integration guide adds one option to the App Router cache provider — enableCssLayer: true — which wraps every Emotion-injected style in a mui layer. The global stylesheet then declares the order once:

@layer theme, base, mui, components, utilities;
Enter fullscreen mode Exit fullscreen mode

Read left to right, lowest to highest. Tailwind's base reset ranks below MUI's components (so the reset can't strip a button), and utilities ranks above them (so a hidden class on an MUI component actually hides it). One line of config, one line of CSS, and two styling systems stop fighting. That was the working state.

Breakage #1: StyledEngineProvider injectFirst

The first plausible line came from older MUI advice about making other CSS beat MUI: wrap the tree in <StyledEngineProvider injectFirst>. In the App Router it did something subtle — it created a second Emotion cache, without the layer option, that shadowed the layered one from the cache provider. MUI styles went back to being unlayered, which means they participated in the normal cascade as ordinary high-specificity rules… and Tailwind's layered utilities, being in a layer, ranked below every unlayered rule.

The symptom was a single md:hidden on an MUI icon button that never hid. No error, no warning, just a class that did nothing. The fix was deletion: the cache provider with enableCssLayer is the integration; anything that constructs another cache undoes it.

Breakage #2: prepend: true

The second line was more plausible still. prepend: true on the cache provider makes Emotion insert its styles at the front of <head> instead of the end — commonly recommended so "other CSS can override MUI." Reasonable in an unlayered world. Catastrophic in a layered one, for a reason that took a while to see:

A cascade layer's priority is fixed by where it's first declared in document order. The global stylesheet declares theme, base, mui, components, utilities. But with prepend: true, Emotion's @layer mui { … } block landed in <head> before that stylesheet. So the browser met mui first — making it the lowest layer of all — and only then read the ordering declaration, which can't demote layers it's already ranked. Tailwind's base preflight now outranked every MUI component style.

Three preflight rules did the visible damage:

  • border: 0 solidevery input outline erased.
  • background-color: transparentevery button flattened to plain text.
  • opacity: 1the inputs MUI keeps invisible got un-hidden, so a <Select> printed its raw value on top of its label.

Why it hid for so long

The cruel part: only client-inserted styles are affected. Server-rendered pages carry just the baseline global styles in their markup, and those looked correct throughout. The component styles that break are inserted by Emotion after hydration — which means everything behind the auth guard (the entire admin console, client-rendered by construction) was broken, while the public landing page and legal pages that everyone checks were fine. The bug was invisible on every page you'd screenshot and total on every page you'd use.

The fix, again, was deletion — enableCssLayer: true alone is what the integration guide specifies, and nothing else. The repo's own instructions now carry both breakages as named rules, because each looked like a fix at the time.

The mental model that would have prevented both

  1. Layers are ranked by first declaration, not by the ordering statement alone. The @layer a, b, c; line only ranks layers the browser hasn't already met. Anything that injects a layer block earlier in the document than that statement wins the race and loses the priority.
  2. Unlayered styles beat all layered styles. A second cache without the layer option doesn't "join" the cascade — it sits above every layer, silently.
  3. Injection position is a cascade decision. In a layered world, "put my styles first" means "make my layer lowest." prepend and injectFirst are answers to a question layers already solved.
  4. Test client-rendered pages. If your styles are inserted post-hydration, your SSR screenshots are not evidence.

What I took away

  • Two styling systems, one integration, zero extras. enableCssLayer: true and the ordering line — anything added "for safety" is a regression.
  • First declaration ranks the layer. Read your <head> order, not just your stylesheet.
  • Preflight is a weapon once it outranks your components: borders, backgrounds and opacity vanish together.
  • Client-only breakage hides behind auth. Audit the pages that render after hydration.
  • Write the failure modes down as rules. Both of these looked correct; the note in the repo is what stops the third attempt.

Next up

Part 39 closes the fourth batch with the third polish montage: brand and pack size on scanned items, the Android alert that silently drops its fourth button, the edge-to-edge keyboard trap in bottom sheets, and the day the app got a space in its name.

What's the "recommended" line in your CSS setup that predates cascade layers — and have you checked what it does now?


0 reactions · 0 comments

Discuss on dev.to