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.js automatically when present.
  • Familiarity with the injection-plus-validation model from the build-pipeline layer automation overview.
  • Node.js ≥ 18 and @csstools/stylelint-plugin-cascade-layers and postcss-import as dev dependencies.
  • The canonical stack this project standardises on: @layer reset, base, themes, components, utilities, overrides;.

Step-by-step procedure

Where the layer order is decided in a Vite build Source partials enter a PostCSS pass that assigns each file to a layer, then Vite concatenates and minifies. The emitted bundle opens with the manifest statement, which is the only point where the browser learns the order. source partials one folder per layer order not yet fixed PostCSS pass wraps each file in @layer <name> Vite bundle concatenate + minify chunk splitting here emitted CSS @layer reset, base, theme, components… line 1, always The manifest must survive minification and chunk splitting — those are the two stages that silently reorder or drop it, and neither reports anything when they do. Assert on the emitted file, never on the source.

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 0

What 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:

Three assertions that make the pipeline trustworthy Three checks run against the built stylesheet: the manifest appears as the first statement, no rule sits outside a layer, and the set of layer names matches the manifest exactly. 1 · the manifest is the first statement in the bundle head -c 120 dist/assets/index.css 2 · zero rules sit outside a layer catches a partial the PostCSS pass never matched 3 · the layer names in the output equal the manifest, exactly catches a dependency that shipped its own @layer statement
  1. Read the emitted CSS. After npm run build, open a file in dist/assets/*.css and 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.
  2. 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).
  3. Trace a known override. In Elements → Computed, expand a property that a utilities rule should win and verify the winning declaration shows [utilities] in brackets, not [components].
  4. 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.

Which build stage produced the symptom Three symptoms mapped to stages: styles applying in the wrong order points at chunk splitting, a missing manifest points at minification, and an unexpected layer name points at a dependency. symptom stage that caused it order differs between routes chunk splitting — a chunk loads before the manifest manifest missing in production minification treated the statement as dead code an unfamiliar layer name a dependency shipped its own @layer statement

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 0

FAQ

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.


Up: Build-Pipeline Layer AutomationArchitecture Patterns & Design System Scaling