Layering CSS Modules Inside a Component Layer
CSS Modules solve name collisions completely and ordering not at all. Two modules that legitimately style the same element still compete on specificity and then source order — and source order in a bundled application is decided by chunk loading, which changes with routing. Putting module output inside a cascade layer replaces that with a declared answer. This extends component layer isolation in the architecture patterns reference.
Prerequisites
A build using CSS Modules (Vite, webpack or Next.js), and a manifest that reserves a band for component styles.
/* WHY sub-layers under `components`: shared design-system modules should lose
to feature modules that specialise them, and that relationship must not
depend on which chunk the router happened to load first. */
@layer reset, base, theme, components, utilities;
@layer components.shared, components.feature;Step 1: Confirm the ordering problem is real
/* Two modules, both legitimately targeting the same element. */
/* packages/ui/Card.module.css → .Card_root__a1b2 */
.root { padding: 16px; border-radius: 8px; }
/* apps/checkout/Summary.module.css → .Summary_card__c3d4 */
.card { padding: 24px; }// WHY this is ambiguous today: both classes are on the element, both are
// 0-1-0, so the winner is whichever rule the bundler emitted LAST — which
// depends on chunk order, and therefore on the route the user arrived from.
<div className={`${cardStyles.root} ${summaryStyles.card}`} />What this does: demonstrates the failure. It is intermittent by nature: the component renders correctly when reached from one route and incorrectly from another, which is why it is usually reported as a flaky bug rather than a cascade problem.
Step 2: Wrap module output at build time
// vite.config.js
// WHY a PostCSS plugin rather than editing each module: modules are compiled
// individually, so the wrapper has to run per file — and it must run AFTER
// the module transform, when class names are already hashed.
function wrapModulesInLayer() {
return {
postcssPlugin: 'wrap-css-modules',
Once(root, { result }) {
const file = result.opts.from ?? '';
if (!file.includes('.module.')) return; // not a module
if (root.first?.type === 'atrule' && root.first.name === 'layer') return;
// WHY the path decides the sub-layer: it needs no per-file
// configuration and cannot disagree with where the file actually lives.
const layer = file.includes('/packages/ui/')
? 'components.shared'
: 'components.feature';
const inner = root.clone().removeAll();
root.each((node) => inner.append(node.clone()));
root.removeAll();
root.append({ name: 'layer', params: layer, nodes: inner.nodes });
},
};
}
wrapModulesInLayer.postcss = true;
export default {
css: { postcss: { plugins: [wrapModulesInLayer()] } },
};What this does: every module’s compiled rules land in a declared layer, so the bundler’s emission order stops being load-bearing.
Step 3: Split shared and feature modules
/* WHY two sub-layers rather than one: a feature module specialising a shared
component is the single most common cross-module conflict, and this makes
the answer structural instead of accidental. */
@layer components.shared, components.feature;/* packages/ui/Card.module.css → compiled to: */
@layer components.shared {
.Card_root__a1b2 { padding: 16px; border-radius: 8px; }
}
/* apps/checkout/Summary.module.css → compiled to: */
@layer components.feature {
.Summary_card__c3d4 { padding: 24px; } /* wins, always */
}What this does: encodes the design-system relationship in the stack. A shared component can now be specialised without !important, without a deeper selector, and without knowing anything about chunk order.
Step 4: Route :global rules to the right layer
/* apps/checkout/reset.module.css */
/* WRONG — a page-wide reset inheriting component-layer priority, or worse
staying unlayered if the wrapper skipped it.
:global(body) { margin: 0; } */
/* RIGHT — the escape hatch names its own destination, and the build plugin
reads it. A global rule should sit in the layer that matches its SCOPE,
not the layer that matches the file it happens to live in. */
@layer reset {
:global(body) { margin: 0; }
}What this does: stops global rules acquiring component priority. This is the failure that makes people distrust the whole arrangement — a :global reset that beats the design system because it inherited a component layer’s position.
Verification
- Inspect a component styled by two modules. Both rules must show layer annotations, and the winner must be the
components.featureone. - Prove chunk order no longer matters — visit the same page via two different routes and compare:
getComputedStyle(document.querySelector('[data-testid="summary-card"]')).paddingTop;- Confirm no module output is unlayered:
grep -L "@layer" dist/assets/*.css # should list nothing- Check
:globalrules landed where intended, not in a component layer.
Troubleshooting
A module’s styles disappeared after wrapping.
: Double wrapping produced components.feature.components.feature. Guard on an existing @layer first node, as in the plugin above.
The plugin does not run on .module.css files.
: In some configurations the modules transform runs after user PostCSS plugins, so the file the plugin sees is not the final output. Move the wrapper to a post-processing step over the emitted CSS instead.
composes stopped working.
: It has not — composes resolves at build time into extra class names on the element. What changed is which of those classes wins, and that is now decided by layer. If the result is wrong, the two classes are in the wrong sub-layers.
Server-rendered pages differ from client-navigated ones. : The server emitted all module CSS in one bundle while the client loads chunks. With layers, both should resolve identically; if they do not, one path is bypassing the wrapper.
A third-party component library’s modules are still unlayered.
: Its CSS ships pre-compiled, so your PostCSS step never sees it. Wrap it at import time with @import … layer(components.shared) — see wrapping CSS-in-JS libraries in cascade layers for the runtime variant of the same problem.
Complete working example
// vite.config.js — complete configuration.
import { defineConfig } from 'vite';
/** Wrap every compiled CSS Module in a cascade layer chosen by its path.
* WHY path-based: it requires no per-file annotation and cannot disagree
* with where the file actually lives, so a module moved between packages
* gets the right layer automatically. */
function wrapCssModules({ shared = '/packages/ui/' } = {}) {
return {
postcssPlugin: 'wrap-css-modules',
Once(root, { result }) {
const file = result.opts.from ?? '';
if (!file.includes('.module.')) return;
// WHY the idempotence guard: dev-server hot reload re-runs plugins on
// the same AST, and a second wrap creates a nested layer path that
// matches nothing you declared.
if (root.first?.type === 'atrule' && root.first.name === 'layer') return;
const layer = file.includes(shared)
? 'components.shared'
: 'components.feature';
const inner = root.clone().removeAll();
root.each((node) => inner.append(node.clone()));
root.removeAll();
root.append({ name: 'layer', params: layer, nodes: inner.nodes });
},
};
}
wrapCssModules.postcss = true;
export default defineConfig({
css: {
modules: {
// WHY a readable pattern: the hashed name appears in DevTools next to
// its layer annotation, and "Card_root__a1b2 @layer components.shared"
// tells you both the origin and the priority at a glance.
generateScopedName: '[name]_[local]__[hash:base64:4]',
},
postcss: { plugins: [wrapCssModules()] },
},
});/* src/styles/layers.css — imported first from the app entry.
WHY the sub-layers are declared here and not inferred: sibling order is a
real decision (feature specialises shared), so it belongs in the reviewed
manifest rather than emerging from whichever module compiled first. */
@layer reset, vendor, base, theme, components, utilities, overrides;
@layer components.shared, components.feature;/* apps/checkout/Summary.module.css — authored normally, wrapped at build. */
.card {
/* WHY no !important and no deeper selector: components.feature sits above
components.shared, so this wins over the design system's Card by layer
order alone. */
padding: var(--space-6);
}
/* WHY the explicit layer around :global: a page-wide rule should carry the
priority of what it DOES, not of the file it lives in. */
@layer reset {
:global(body) { margin: 0; }
}Frequently Asked Questions
If CSS Modules already scope class names, why add layers?
Because scoping and ordering are different problems. Modules guarantee that two teams’ .card classes never collide by accident — but when a feature module deliberately styles the same element as a shared component, both hashed classes are on the element, both have identical specificity, and the winner is whichever rule was emitted last. In a code-split application that order comes from chunk loading, so it varies by route.
Does wrapping modules in a layer break composes?
Not at all. composes is resolved by the compiler into an additional class name on the element, so it never depends on cascade position in the first place. What layering changes is which of the composed classes wins when they set the same property — and since composition normally means “this component extends that one”, having the composing module’s layer win is usually exactly right.
Where should :global rules go?
Into the layer that matches what the rule does, not the file it lives in. A :global(body) reset belongs in reset; a global theme override belongs in theme. Leaving it in the component layer gives a page-wide rule component-level priority, which is how a single module ends up quietly overriding the design system — and it is the failure that makes teams blame layers for a problem the escape hatch introduced.
Related
- Component Layer Isolation — the sub-layer structure this fits into
- Structuring @layer for Scalable Component Libraries — organising the shared band
- Cascade Layers vs CSS Modules vs Shadow DOM — what each mechanism actually solves
- Wrapping CSS-in-JS Libraries in Cascade Layers — the runtime-injection version of this problem
Up: Component Layer Isolation → Architecture Patterns & Design System Scaling