Wrapping Legacy CSS in a Single Legacy Layer
This is the highest-leverage afternoon in a layer adoption: five lines of new CSS, no edits to the existing stylesheet, and afterwards every rule anyone writes can outrank the monolith without !important. The procedure below is the concrete first phase of incremental migration strategies, part of the browser support, compatibility and migration reference.
Prerequisites
You need to know that layered styles lose to unlayered styles, and that lower layers lose to higher ones. Those two facts are the whole mechanism.
/* The end state of this page — five lines that change nothing visually
and change everything architecturally. */
@layer legacy, reset, base, theme, components, utilities;
@import url("/css/app.css") layer(legacy);Tooling: your existing build, plus a screenshot-diff tool for the verification step.
Step 1: Audit for CSS that is already layered
This is the only step that can produce a regression, so do it first.
# Any file already using @layer. If this returns nothing, the wrap is
# behaviour-preserving and you can proceed with confidence.
grep -rln "@layer" src/ public/ --include="*.css"
# Runtime injection is the other risk — these styles stay unlayered.
grep -rn "insertRule\|adoptedStyleSheets\|createElement('style')" src/ --include="*.js" --include="*.ts"What this does: separates the safe case from the risky one. If nothing is layered yet, every rule in the application is currently unlayered, they all move into legacy together, and their relative order is untouched — the page cannot change. If some CSS is already layered, that code has been losing to the monolith, and wrapping the monolith below it reverses the relationship.
Step 2: Create the entry stylesheet
/* src/styles/entry.css — the only new file this change adds.
WHY the order: `legacy` is declared FIRST so it sits at the bottom of the
stack. Everything extracted from it later lands in a higher layer and wins
automatically, which is what allows an extraction and its deletion to ship
in separate commits. */
@layer legacy, reset, base, theme, components, utilities;
/* WHY @import rather than a bundler concatenation: the layer() annotation is
part of the import syntax, so this is the only place the assignment can be
expressed without editing app.css itself. */
@import url("/css/app.css") layer(legacy);What this does: registers the stack and assigns the entire monolith to the lowest band, without touching a single existing rule.
Step 3: Swap the document link
<!-- BEFORE -->
<link rel="stylesheet" href="/css/app.css">
<!-- AFTER — one line changed. WHY: the browser now parses entry.css first,
which registers the layer order before app.css is fetched and parsed. -->
<link rel="stylesheet" href="/css/entry.css">What this does: activates the wrap. Note the network shape changes — app.css is now discovered after entry.css parses, adding one round trip. For a production site, preload it:
<!-- WHY: restores the original discovery timing so the wrap costs no LCP. -->
<link rel="preload" as="style" href="/css/app.css">
<link rel="stylesheet" href="/css/entry.css">Step 4: Deal with runtime-injected styles
// WHY: CSS injected at runtime lands unlayered at the end of the document and
// keeps outranking every layer. Wrapping the text before inserting it brings
// the sheet under your control.
function injectLayered(cssText, layerName = 'legacy') {
const sheet = new CSSStyleSheet();
sheet.replaceSync(`@layer ${layerName} { ${cssText} }`);
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
}What this does: closes the gap the audit found in Step 1. Anything a framework injects that you cannot route through this helper stays unlayered — record those cases; they become the last items in the migration.
Step 5: Verify with a visual diff
# Capture the same routes before and after the wrap. A behaviour-preserving
# wrap produces an empty diff — anything else is a finding, not noise.
npx playwright test tests/visual --update-snapshots # on main, before
git checkout wrap-legacy-layer
npx playwright test tests/visual # afterWhat this does: turns “it looks fine” into evidence. Run it across the routes with the heaviest CSS, not the simplest ones.
Verification
- Inspect any element. Its rules should now carry a
@layer legacyannotation in the Styles panel. - Confirm the layer order registered correctly:
// Should print the manifest in declaration order, legacy first.
[...document.styleSheets].flatMap((s) => {
try { return [...s.cssRules]; } catch { return []; }
}).filter((r) => r instanceof CSSLayerStatementRule).flatMap((r) => r.nameList);- Add a throwaway rule in a higher layer and confirm it beats a high-specificity legacy selector without
!important. That is the capability the whole commit exists to buy. - Check the network waterfall —
app.cssshould still start loading early if you added the preload.
Troubleshooting
The page renders completely unstyled.
: The @import is not the first thing in the file, or a comment or rule precedes it. @import is only valid after @charset and @layer statements; anywhere else it is dropped silently along with the entire stylesheet it referenced.
A handful of components changed appearance. : Those are the already-layered files the audit was looking for. They were losing to the unlayered monolith and now win. Decide per case whether the new result is correct — usually it is, and the old behaviour was the bug.
Styles arrive noticeably later than before.
: The import chain added a round trip. Add <link rel="preload" as="style"> for the imported file, or have the build inline the import so the bundle is a single request.
A third-party widget still overrides everything.
: It injects unlayered styles at runtime. Route it through the injectLayered helper if you control the call site; otherwise it belongs on the list of exceptions to handle with Shadow DOM or a CSSOM interception.
The layer annotation shows (anonymous) in DevTools.
: Something wrote @layer { … } with no name, creating an anonymous layer. Anonymous layers cannot be referenced or reordered later; find it and give it a name.
Complete working example
/* ============================================================
src/styles/entry.css — the complete beachhead
============================================================ */
/* WHY: the full target stack is declared now, even though four of these
layers are still empty. Reserving the positions means the first extraction
needs no manifest change, and the order is reviewed once rather than
incrementally under pressure. */
@layer legacy, reset, base, theme, components, utilities;
/* WHY: one import per original <link>, in the original order. Source order
inside `legacy` is preserved exactly, so every specificity and
order-of-appearance tie resolves as it did before this commit. */
@import url("/css/vendor-bundle.css") layer(legacy);
@import url("/css/global.css") layer(legacy);
@import url("/css/components.css") layer(legacy);
@import url("/css/pages.css") layer(legacy);
/* WHY: the first extraction can start immediately — a rule written here
outranks anything in `legacy` regardless of selector weight, so the old
declarations can stay in place during a parallel-run window. */
@layer reset {
*, *::before, *::after { box-sizing: border-box; }
}<!DOCTYPE html>
<html lang="en">
<head>
<!-- WHY: preload the largest imported file so the extra round trip
introduced by the import chain does not delay first paint. -->
<link rel="preload" as="style" href="/css/components.css">
<link rel="stylesheet" href="/css/entry.css">
</head>
<body><!-- unchanged --></body>
</html>Frequently Asked Questions
Will wrapping change specificity behaviour inside the legacy file?
Not at all. Within a single layer the cascade behaves exactly as it did when the rules were unlayered — specificity first, then order of appearance. What the wrap changes is how those declarations compare against rules in other layers, and immediately after the wrap there are none. That is precisely why the commit is safe to ship on its own.
Should the legacy layer be named legacy?
Something visibly temporary is worth the small cost. The name appears in every DevTools annotation, so legacy continuously signals that the code inside it is on its way out. Neutral names like app or main invite new work to be written into the layer, which quietly converts the beachhead into a permanent second home for CSS.
What if the monolith is loaded by several link tags?
Replace them with one entry stylesheet containing an @import … layer(legacy) per file, listed in the original link order. Because the imports resolve in that sequence, source order inside the layer matches what the browser saw before, and any rule that previously won a source-order tie still wins it. Preload the largest one or two files to keep the discovery timing you had.
Related
- Incremental Migration Strategies — the full migration this commit begins
- Migrating a Legacy Stylesheet to Layers File by File — the extraction loop that follows
- Measuring Migration Progress With a Layer Coverage Report — the metric that keeps it moving
- Migrating Bootstrap to @import layer() — the same import mechanics applied to a framework
Up: Incremental Migration Strategies → Browser Support, Compatibility & Migration