Fixing Micro Frontend Style Conflicts with CSS @layer | Cascade Layers Isolation Strategy
When I first worked on a Micro Frontend (MFE) project, I remember being quite surprised to see a button style deployed by Team A showing up altered on Team B's screen. Back then, we got by with CSS Modules to hash class names or by prepending team prefixes to selectors — but it was only a matter of time before the !important wars began.
If you're running an MFE or have ever struggled with CSS conflicts, this post is for you. MFE is an architecture where multiple independently deployed frontend apps are mounted onto a single DOM, and when each team manages CSS separately, global styles inevitably collide. CSS @layer, which major browsers began supporting in early 2022, is a tool that resolves this chronic problem quite elegantly. With browser support exceeding 96% as of 2026, this feature is no longer "future technology" — it's something you can use in production today.
Once you understand @layer, you gain the ability to control style priority by declaration alone, without relying on selector specificity or file load order.
Core Concepts
CSS @layer: Adding Layers to the Cascade
The CSS cascade is the mechanism that determines which style rule wins when multiple rules apply to the same element. Traditionally, this decision followed three criteria: specificity, source order, and origin. But in environments like MFE where multiple teams inject CSS simultaneously, these rules produce unpredictable results.
@layer adds an explicit layer structure to this cascade. Because the layer declaration order itself becomes the priority, you can predict the outcome regardless of which file loaded first or which selector is more specific.
/* Shell (host) app — declare the entire layer order here, only once */
@layer reset, tokens, vendor, shell, mfe-a, mfe-b, overrides;That's all it takes. Layers declared later have higher priority, so mfe-b styles will always beat mfe-a for the same selector. You can codify the inter-team agreement of "my CSS should take precedence over yours" directly in code.
Cascade Layers — A feature that adds named layers to the CSS cascade. Within a layer, existing specificity rules still apply, but in conflicts between layers, the layer declaration order takes precedence over specificity.
Layer Declaration and Style Definition Must Be Separated
When you first start using @layer, you may mix declarations and definitions in a single block — but this gets quite confusing during maintenance.
/* ✅ Correct pattern: order declaration separate, style definition separate */
@layer reset, vendor, shell, mfe-a; /* Declaration: only once, at the top of the file */
@layer reset { /* Definition: actual styles */
*, *::before, *::after { box-sizing: border-box; }
}
@layer shell { /* Definition: actual styles */
.nav { background: var(--color-surface); }
}A block like @layer reset, vendor, shell; that only declares order and a block like @layer reset { ... } that contains actual styles serve different roles. Managing the initial declaration in a single place within the shell file, as a separate block, makes the overall layer structure much clearer.
Characteristics of Unlayered Styles
I wasn't aware of this at first and encountered some confusing situations — any style that doesn't belong to any layer has higher priority than all layers.
@layer shell {
.btn { background: blue; } /* Inside a layer */
}
.btn { background: red; } /* Outside any layer — always wins */The result is red. This characteristic can actually be useful when incrementally adopting @layer in an existing project. You can leave existing code as-is and only put newly added styles inside layers, naturally preserving the priority of existing styles.
Important: If one of the MFE teams doesn't use layers, that team's styles will override the styles of all teams using layers. This is exactly why it's recommended to enforce layer wrapping in the build pipeline with PostCSS.
For reference, it's also possible to nest layers within layers (nested @layer). You can create a hierarchy within a team with @layer mfe-a { @layer base, components; }, which is useful for teams that have grown in scale.
MFE Style Conflict Patterns and the @layer Approach
Here's a summary of the most common patterns where CSS conflicts occur in MFE:
| Problem | Traditional Approach | @layer Approach |
|---|---|---|
Team A and Team B both use .card class |
Prefix naming conventions, CSS Modules | Isolate each team in a separate layer |
| Third-party CSS like Bootstrap overrides custom styles | Add !important |
Contain third-parties in a vendor layer |
| MFE style load order varies across environments | Strict load order management | Layer declaration order is the absolute reference |
Practical Application
Example 1: Defining a Global Layer Contract in the Shell App
In an MFE architecture, layer declaration must happen only once in the shell (host) app, as the very first thing. This declaration acts as the constitution for the entire style priority system.
/* host/shell.css */
@layer reset, tokens, vendor, shell, mfe-a, mfe-b, overrides;
@layer reset {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
}
@layer tokens {
:root {
--color-primary: #0070f3;
--color-surface: #ffffff;
--spacing-base: 8px;
--font-size-base: 1rem;
}
}
@layer shell {
.layout { display: grid; grid-template-columns: 240px 1fr; }
.nav { background: var(--color-surface); }
}| Layer | Role | Owner |
|---|---|---|
reset |
Reset browser default styles | Shell team |
tokens |
Design tokens (colors, spacing, etc.) | Design system team |
vendor |
Third-party libraries | Automatically isolated |
shell |
Global layout, navigation | Shell team |
mfe-a, mfe-b |
Individual micro-app styles | Each team |
overrides |
Emergency overrides | Requires governance |
Example 2: Isolating Third-Party CSS in the Vendor Layer
The most satisfying moment when I first applied this was third-party isolation. When using libraries like Bootstrap or Ant Design that inject large amounts of global styles in an MFE environment, without layer isolation another team's styles can be unexpectedly overridden.
/* Isolate external CSS libraries inside the vendor layer */
@import url("bootstrap.min.css") layer(vendor);
@import url("ant-design.min.css") layer(vendor);
/* Now customize freely in layers higher than vendor */
@layer shell {
.btn {
background: var(--color-primary);
border-radius: 6px;
}
}The @import ... layer(vendor) syntax locks an entire external CSS file inside the specified layer. Honestly, I was quite impressed when I first discovered this.
One more thing worth noting: even if Bootstrap uses !important inside the vendor layer, it still has lower priority than normal unlayered styles outside the layer. !important inside the vendor layer only causes specificity battles within that same layer — it cannot intrude on higher layers or styles outside any layer.
Example 3: Automatic Per-MFE Layer Wrapping in Module Federation Environments
The most debated point within the team when we first applied this was "do all developers have to manually declare layers in every file?" By leveraging PostCSS (a build-time tool that transforms CSS), you can automatically apply layers at build time without developers having to touch individual files manually.
When loading remote apps with Module Federation (a Webpack/Rspack-based MFE bundle sharing tool), a simple custom PostCSS plugin can automate this work.
// mfe-a/postcss.config.js
const wrapInLayer = (layerName) => ({
postcssPlugin: 'postcss-layer-wrap',
Once(root, { postcss }) {
const layer = postcss.atRule({ name: 'layer', params: layerName });
layer.append(root.nodes.map(n => n.clone()));
root.removeAll();
root.append(layer);
}
});
module.exports = {
plugins: [wrapInLayer('mfe-a')]
};/* mfe-a/styles.css before build */
.card { padding: 16px; }
.header { font-size: 1.5rem; }
/* After build (automatically transformed) */
@layer mfe-a {
.card { padding: 16px; }
.header { font-size: 1.5rem; }
}| Stage | Action | Owner |
|---|---|---|
| At build time | PostCSS wraps the entire CSS file in @layer mfe-a { } |
CI/build pipeline |
| At runtime | Priority determined by the shell's layer declaration order | Browser |
| When a conflict occurs | Only modify the shell's layer declaration | Shell team |
Example 4: Combining CSS Modules with @layer (Dual Isolation)
If you need even more robust isolation, you can use CSS Modules and @layer together. CSS Modules prevent selector conflicts with hashed class names, while @layer controls global priority. The two approaches combined compensate for each other's weaknesses.
/* mfe-b/Button.module.css */
@layer mfe-b {
.button {
background: var(--color-primary);
padding: calc(var(--spacing-base) * 1.5) calc(var(--spacing-base) * 3);
border: none;
cursor: pointer;
}
.button:hover {
opacity: 0.85;
}
}In the build output, .button is transformed into a hashed class while simultaneously being placed inside the mfe-b layer. The CSS parsed by the actual browser looks like this:
/* Build output */
@layer mfe-b {
.button_x7k2p {
background: var(--color-primary);
padding: calc(var(--spacing-base) * 1.5) calc(var(--spacing-base) * 3);
border: none;
cursor: pointer;
}
.button_x7k2p:hover {
opacity: 0.85;
}
}Thanks to hashing, another team using a .button class won't cause selector conflicts, and thanks to @layer, priority is determined by the layer declaration order. This approach prevents both selector conflicts and cascade conflicts.
Integration with CSS-in-JS
When using Emotion or styled-components, they dynamically inject <style> tags at runtime, so integration with @layer requires a bit more attention. The simplest approach is to fix the CSS-in-JS library's insertion point to come after the layer declaration.
// Emotion: specify insertionPoint to be after layer declaration
import createCache from '@emotion/cache';
const cache = createCache({
key: 'mfe-a',
insertionPoint: document.querySelector('#emotion-insertion-point'), // position after shell.css
});// MUI: control insertion order with injectFirst
import { StyledEngineProvider } from '@mui/material';
<StyledEngineProvider injectFirst>
<App />
</StyledEngineProvider>Due to the nature of runtime injection, perfectly aligning with the layer hierarchy is difficult. If CSS-in-JS is your primary tech stack, consider intentionally designing styles to be injected in the unlayered area, or adopting a strategy where that MFE's styles always have the highest priority, similar to an overrides layer.
Pros and Cons Analysis
Honestly, @layer is not a silver bullet. Every tool has limitations, and the adoption cost varies depending on your team's situation.
Advantages
| Item | Description |
|---|---|
| Predictable priority control | Style priority can be clearly defined by layer declaration order alone, without !important |
| Load order independence | Regardless of what order files load, the layer declaration order acts as the absolute reference |
| Incremental adoption | Can start with just @layer wrapping without rewriting all existing code |
| Easier debugging | Layer-by-layer style structure can be visually inspected in browser DevTools |
| Third-party control | Entire external libraries can be isolated in one layer to prevent override conflicts |
| Design token sharing | The tokens layer can be designed as a shared reference structure for all MFEs |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Requires inter-team layer contract | Global layer order must be agreed upon in advance, creating implicit coupling | Formalize shell.css in the shell repo as the single source of truth |
| Unlayered style pitfall | A single MFE not using layers can neutralize the entire layer structure | Force layer usage in the pipeline with PostCSS automatic wrapping |
| Load timing issues | The shell's @layer declaration must be loaded before MFE styles |
Guarantee load order with <link rel="preload"> or bundle settings |
| Disconnected from Shadow DOM | @layer inside a Shadow DOM boundary does not connect to global layers |
Run a separate isolation strategy alongside Shadow DOM-using components |
| Complex CSS-in-JS integration | Emotion and styled-components inject styles at runtime, requiring separate handling | Use insertionPoint API or injectFirst option |
Shadow DOM — A technology that creates a completely isolated DOM tree inside an HTML element. Styles are completely isolated from the outside, and the global layer structure of
@layercannot cross Shadow DOM boundaries. This must be accounted for separately in Web Components-based MFEs.
The Most Common Mistakes in Practice
-
A single MFE injecting CSS without a layer — No matter how diligently you apply layer isolation, a single CSS outside a layer can break everything. Adding PostCSS automatic wrapping to the build pipeline can structurally block this problem. Even if a developer accidentally omits a layer, the build step will automatically wrap it.
-
MFE styles loading before the shell's layer declaration — If MFE styles have already been parsed by the browser before the shell's
@layerdeclaration, the layer order may be applied differently from what was intended. It's safest to place the shell CSS at the very top of the HTML<head>. -
Misunderstanding
!importantbehavior inside thevendorlayer —!importantinside thevendorlayer has lower priority than normal styles outside a layer. However, if there is also an!importantoutside a layer, that takes top precedence.@layerdoesn't eliminate!importantitself — rather, the reversal rule for!importantalso follows the layer structure. Not knowing this subtle difference can lead to confusion in unexpected situations.
Closing Thoughts
CSS @layer is a modern tool that lets you resolve style conflict problems in MFE environments through declaration order alone, without !important.
Three steps you can start right now:
-
Start with isolating external CSS libraries. In your current project, try changing your Bootstrap, Tailwind, or other external library import statements to the form
@import url("library.css") layer(vendor);. It's a small change, but you'll immediately feel how much easier third-party style overriding becomes. -
Add a layer declaration file to the shell app. Add the single line
@layer reset, tokens, vendor, shell;to the top of yourshell.cssfile and try moving existing styles into those layers. You can check in the browser DevTools Styles panel how styles are organized by layer. -
When a new MFE app joins, set up PostCSS automatic wrapping — from this point on, even when a new team joins, the layer contract is maintained automatically. All it takes is adding a simple PostCSS plugin to the build pipeline.
References
- CSS @layer | MDN Web Docs
- Learning Cascade Layers | MDN (Korean)
- Cascade Layers Guide | CSS-Tricks
- Integrating CSS Cascade Layers To An Existing Project | Smashing Magazine
- How to scale CSS in micro frontends | LogRocket Blog
- Style Isolation | Module Federation Official Docs
- CSS Layers | Material UI Official Docs
- Setting Style Priority with CSS @layer | Dale Seo
- On CSS Cascade Layers | Witch-Work
- CSS Cascade Layers Explainer | odd-bird