Automating Layer Order with PostCSS and Vite
When a Vite production build splits your CSS and a component chunk registers @layer names before your manifest does, the cascade inverts in production while dev looks fine — this guide, part of Build-Pipeline Layer Automation within Architecture Patterns & Design System Scaling, wires postcss.config.js and vite.config.js so the manifest is guaranteed first everywhere and the order is validated in CI.
Prerequisites
Before starting, make sure you have:
- A working Vite project (v4+) with PostCSS available — Vite runs
postcss.config.jsautomatically when present. - Familiarity with the injection-plus-validation model from the build-pipeline layer automation overview.
- Node.js ≥ 18 and
@csstools/stylelint-plugin-cascade-layersandpostcss-importas dev dependencies. - The canonical stack this project standardises on:
@layer reset, base, themes, components, utilities, overrides;.
Step-by-step procedure
Step 1 — Define one source of truth for the order
Put the ordered layer names in exactly one place so injection, linting, and validation all read the same list.
// layers.config.js — the ONLY place the canonical order is written.
// WHY: a single export feeds the PostCSS injector, the CI validator, and
// the Stylelint allowlist, so those three can never drift out of sync.
export const LAYERS = ["reset", "base", "themes", "components", "utilities", "overrides"];
export const MANIFEST = `@layer ${LAYERS.join(", ")};`;/* src/styles/layers.css — generated to match MANIFEST, imported first.
WHY: contains ONLY the name declaration, no rules, so re-declaring it in
other chunks is idempotent and free of cascade side effects. */
@layer reset, base, themes, components, utilities, overrides;What this does: Establishes the canonical order as data (LAYERS) and as a ready-to-serve stylesheet (layers.css), so every downstream step references one authority instead of a copy-pasted string.
Step 2 — Write the PostCSS injector plugin
This plugin prepends the manifest to any stylesheet that does not already start with it. It is the mechanism that makes per-chunk order guaranteed rather than hoped-for.
// postcss-layer-manifest.js
import { LAYERS } from "./layers.config.js";
const CANONICAL = LAYERS.join(", ");
// WHY: prepend the manifest so the canonical order is registered before any
// rule in THIS file — the browser locks layer order at first sight.
const plugin = () => ({
postcssPlugin: "layer-manifest",
Once(root, { AtRule }) {
const first = root.first;
const present =
first?.type === "atrule" &&
first.name === "layer" &&
first.params.replace(/\s+/g, " ").trim() === CANONICAL;
if (!present) root.prepend(new AtRule({ name: "layer", params: CANONICAL }));
},
});
plugin.postcss = true;
export default plugin;What this does: Turns “the manifest should be first” into an enforced AST operation applied to every file PostCSS processes, including each chunk Vite emits.
Step 3 — Order the PostCSS chain correctly
The plugin sequence is load-bearing. Imports must be inlined before the manifest is prepended, and the optional polyfill must run last.
// postcss.config.js
import postcssImport from "postcss-import";
import layerManifest from "./postcss-layer-manifest.js";
import cascadeLayers from "@csstools/postcss-cascade-layers";
export default {
plugins: [
postcssImport(), // WHY: inline @imports FIRST so no unlayered import sneaks above the manifest
layerManifest(), // WHY: then guarantee the canonical order leads the file
cascadeLayers(), // WHY: polyfill LAST so it sees the full, ordered layer set (drop when unneeded)
],
};What this does: Guarantees the three transforms compose correctly — inline, then inject, then (optionally) polyfill — so the emitted CSS has the manifest first and any polyfilled specificity chains reflect the real order.
Step 4 — Register the manifest in Vite for every chunk
Vite needs two touches: the entry import (controls dev-server order and pins the side-effect) and the css.postcss registration (controls production split chunks).
// vite.config.js
import { defineConfig } from "vite";
import layerManifest from "./postcss-layer-manifest.js";
export default defineConfig({
css: {
// WHY: run the injector per emitted chunk so a code-split route's stylesheet
// still registers the canonical order first in production.
postcss: { plugins: [layerManifest()] },
},
});// src/main.js — layers.css MUST be the first CSS side-effect import.
// WHY: in the dev server Vite serves CSS in import order; importing the
// manifest first registers the order before any component style is injected.
import "./styles/layers.css";
import "./styles/reset.css";
import "./styles/components.css";
import { mountApp } from "./app.js";
mountApp();What this does: Closes the dev/production gap — the entry import fixes the dev-server path, the css.postcss injector fixes every production chunk, so both environments register the same order first.
Step 5 — Validate the emitted order in CI
Source linting cannot see chunk ordering, so assert against the built output.
// scripts/validate-layer-order.mjs
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { MANIFEST } from "../layers.config.js";
const DIST = "dist/assets";
// WHY: match the FIRST @layer statement in each emitted file; it must equal
// the canonical manifest or the browser could register a rival order first.
const firstLayer = /@layer[^;{]*;/;
let failed = false;
for (const file of readdirSync(DIST).filter((f) => f.endsWith(".css"))) {
const css = readFileSync(join(DIST, file), "utf8");
const match = css.match(firstLayer);
const found = match?.[0].replace(/\s+/g, " ").trim();
if (found !== MANIFEST) {
console.error(`✗ ${file}: expected "${MANIFEST}" but found "${found ?? "none"}"`);
failed = true;
}
}
if (failed) process.exit(1);
console.log("✓ every emitted stylesheet leads with the canonical layer order");# WHY: build first so the script inspects real emitted chunks, then lint source.
npm run build && node scripts/validate-layer-order.mjs && npx stylelint "src/**/*.css" --max-warnings 0What this does: Adds the one check that operates on shipped bytes, failing the build if any chunk’s leading @layer statement deviates — the definitive gate that source linting cannot provide.
Verification
Confirm the automation works end to end before trusting it in CI:
- Read the emitted CSS. After
npm run build, open a file indist/assets/*.cssand confirm its first statement is@layer reset, base, themes, components, utilities, overrides;. Do this for a code-split route’s chunk specifically, not just the main bundle. - Check the live layer tree. Serve the production build and open Chrome DevTools Elements → Styles; using the Cascade Layers grouping, confirm no rule appears under
(unlayered). - Trace a known override. In Elements → Computed, expand a property that a
utilitiesrule should win and verify the winning declaration shows[utilities]in brackets, not[components]. - Prove the gate bites. Temporarily add an unlayered
.x { color: red }to a source file and a stray@layer overrides, utilities;to a partial; run the CI command and confirm both Stylelint and the validator fail.
Troubleshooting
Production cascade differs from dev. Only one injection path is active. Dev works because Vite serves merged CSS in import order (Step 4’s entry import), but production splits chunks — confirm css.postcss in vite.config.js also registers the injector so each chunk leads with the manifest.
A rule shows under (unlayered) in DevTools. Something bypassed PostCSS. The usual causes are a runtime CSS-in-JS insertion, a <style> block, or a plain @import url("x.css") with no layer() that postcss-import did not inline. Move postcss-import to the front of the chain and lint for bare imports.
layers.css is missing from the build. An empty stylesheet got tree-shaken. Import it as an explicit side-effect and add it to the package’s sideEffects list so the bundler cannot eliminate it; the per-chunk injector is the backup.
The polyfill emits wrong precedence. @csstools/postcss-cascade-layers ran before the manifest or before postcss-import, so it computed specificity chains from an incomplete layer set. Enforce the order: import inlining, then manifest injection, then polyfill — exactly as in Step 3.
The validator passes but the cascade is still wrong. Your layers.config.js order does not match your intended precedence. Because every tool reads that one file, fixing the array there corrects the injector, the linter, and the validator together — check that the array itself is right.
Complete working example
A self-contained setup — copy these files and adjust paths to your project.
// layers.config.js
export const LAYERS = ["reset", "base", "themes", "components", "utilities", "overrides"];
export const MANIFEST = `@layer ${LAYERS.join(", ")};`;
// postcss-layer-manifest.js
import { LAYERS } from "./layers.config.js";
const CANONICAL = LAYERS.join(", ");
const plugin = () => ({
postcssPlugin: "layer-manifest",
Once(root, { AtRule }) {
const f = root.first;
const present =
f?.type === "atrule" && f.name === "layer" &&
f.params.replace(/\s+/g, " ").trim() === CANONICAL;
if (!present) root.prepend(new AtRule({ name: "layer", params: CANONICAL }));
},
});
plugin.postcss = true;
export default plugin;
// postcss.config.js
import postcssImport from "postcss-import";
import layerManifest from "./postcss-layer-manifest.js";
import cascadeLayers from "@csstools/postcss-cascade-layers";
export default {
// Order is load-bearing: inline, then inject, then polyfill.
plugins: [postcssImport(), layerManifest(), cascadeLayers()],
};
// vite.config.js
import { defineConfig } from "vite";
import layerManifest from "./postcss-layer-manifest.js";
export default defineConfig({
css: { postcss: { plugins: [layerManifest()] } },
});/* src/styles/layers.css — imported first in src/main.js */
@layer reset, base, themes, components, utilities, overrides;# package.json script: "verify:layers"
npm run build \
&& node scripts/validate-layer-order.mjs \
&& npx stylelint "src/**/*.css" --max-warnings 0FAQ
Do I need the JS entry import if the PostCSS plugin already injects the manifest?
Keep both. The PostCSS injector guarantees the order inside every emitted chunk, but importing layers.css first in the JS entry also controls the order in which Vite’s dev server serves un-split CSS and marks the file as an intentional side-effect so it is not tree-shaken. Together they cover both the dev-server path and the production split-chunk path.
Where in the PostCSS plugin array does the cascade-layers polyfill go?
Last. Run postcss-import first so all partials are inlined, then the manifest injector so the canonical order leads, then @csstools/postcss-cascade-layers so the polyfill sees the complete, correctly-ordered layer set when it rewrites layers into specificity chains. Any other order makes the polyfill compute the wrong precedence — see layer browser support and polyfills for when the polyfill is still needed.
Why validate the emitted bundle when Stylelint already lints the source?
Stylelint reads source files and cannot observe how Vite splits and orders chunks in the production build. A rule can be correctly layered in source yet ship in a chunk that registers a different order first. The post-build assertion reads the actual emitted output — the bytes the browser receives — so it catches ordering regressions that source linting structurally cannot see.
Related
- Build-Pipeline Layer Automation — the parent guide on why layer order must be enforced at build time and the named injection patterns
- Framework Integration & @layer Wrapping — wrapping runtime-injected framework styles that never pass through this PostCSS chain
- Understanding Layer Declaration Order — the first-seen registration rule the whole pipeline exists to protect
Up: Build-Pipeline Layer Automation → Architecture Patterns & Design System Scaling