Migrating a Legacy Stylesheet to Layers File by File
Once the monolith is wrapped, the migration becomes a loop you can run a hundred times without thinking hard: pick a component, move its rules up one layer, watch them win, delete the originals. The discipline that makes it boring — and therefore completable — is doing exactly one thing per commit. This is the extraction phase of incremental migration strategies, within the browser support, compatibility and migration reference.
Prerequisites
The legacy layer wrap must already be in place, so the target layers sit above the monolith:
/* WHY: anything written into `components` beats `legacy` regardless of
selector weight — which is what lets the old rules stay in place during
the parallel run. */
@layer legacy, reset, base, theme, components, utilities;Tooling: DevTools coverage, a component-level screenshot diff, and a specificity report if you have one.
Step 1: Collect every rule that touches the component
Two passes, because the second finds what the first misses.
# Pass 1 — the obvious block, by class prefix.
grep -n "\.card" css/app.css
# Pass 2 — page-scoped overrides written elsewhere, often years later.
grep -nE "^\s*(#[a-z-]+|\.page-[a-z-]+)[^{]*\.card" css/app.css// Pass 3 — the authoritative one: every rule the browser actually applies to
// a rendered instance, including ones whose selectors you would never guess.
// WHY: legacy stylesheets accumulate overrides in unrelated files; matching
// against the live element is the only complete search.
const el = document.querySelector('.card');
[...document.styleSheets].flatMap((s) => {
try { return [...s.cssRules]; } catch { return []; }
}).filter((r) => r.selectorText && el.matches(r.selectorText))
.forEach((r) => console.log(r.parentStyleSheet.href, '→', r.cssText));What this does: produces the complete rule set. Extracting a partial set is the most common cause of a “the migration broke the card on the pricing page” report — the page-scoped override stayed behind in legacy and now loses.
Step 2: Copy the rules verbatim into the target layer
/* src/styles/components/card.css
WHY: not one character of the declarations changes in this commit. The
ONLY variable is the layer, so if anything renders differently, the layer
is the cause and the diagnosis takes seconds. */
@layer components {
.card { border: 1px solid #d4c89a; padding: 24px; background: #fffdf5; }
/* Copied exactly as found, ID selector and all. Simplifying this is
step 4's job, in its own commit. */
#main .page-pricing .card { padding: 32px; }
.card .card__title { font-size: 18px; font-weight: 600; }
}What this does: puts the component above legacy, where it wins every conflict with its own old copy. The verbatim discipline is the whole point — an extraction that also “tidies up” produces regressions nobody can attribute.
Step 3: Deploy the parallel run
Ship with both copies present. The new rules win; the old ones sit inert.
/* legacy/app.css — UNCHANGED in this commit. The .card rules are still here
and still parsed; they simply lose to the components layer now.
WHY keep them: rollback is deleting one new file, with no deploy of the
legacy bundle and no risk of a bad revert reintroducing something else. */What this does: buys a safe observation window. Keep it short — a week, not a quarter — because two live copies of a rule is a state that gets forgotten.
Step 4: Simplify the specificity the layer made redundant
Now, in a separate commit, remove the weight that only existed to win a fight that no longer happens.
@layer components {
.card { border: 1px solid var(--color-border); padding: var(--space-6); }
/* BEFORE: #main .page-pricing .card { padding: 32px; }
AFTER: the ID and the page scope existed to beat the base .card rule in
the same unlayered file. Inside a layer, source order already decides
between two .card rules, so the weight is dead code. */
.card--roomy { padding: var(--space-8); }
}What this does: converts the migration from a relocation into an actual improvement. This is also where hard-coded values become tokens — again, one concern per commit.
Step 5: Delete the legacy originals and record the move
# Remove the extracted block from the monolith, then confirm the metric moved.
grep -c ";" css/app.css # declarations left in legacy
grep -rc ";" src/styles/components/ | awk -F: '{s+=$2} END {print s}'What this does: completes one iteration and produces the number that makes progress visible. A migration without this step feels endless even when it is going well.
Verification
- Inspect the component. Its winning rules should be annotated
@layer components, with thelegacycopies struck through beneath them. - Run the component’s visual diff at every breakpoint and in every state the component supports — the parallel run makes a mistake cheap to fix, but only if you find it.
- Check the page-scoped variants specifically. They are the rules most likely to have been missed in Step 1.
- After deletion, confirm nothing else depended on the removed selectors:
# Anything still referencing the old page-scoped hooks in markup or JS.
grep -rn "page-pricing" src/ templates/ --include="*.html" --include="*.js"Troubleshooting
The component looks right except on one page.
: A page-scoped override stayed in legacy and now loses to the extracted base rule. Move it up too, as a modifier class rather than a page selector.
A rule extracted correctly but a neighbouring component broke. : The extracted selector was broader than it appeared — a bare element or attribute selector that also matched elsewhere. Narrow it in the simplify commit and re-verify both components.
Deleting the legacy copy changed the rendering.
: Something in legacy was relying on source-order proximity to the deleted block. Search for the affected selector and extract that rule in the same iteration.
The extraction made a !important stop working.
: Important declarations compete in inverted layer order, so moving a rule up a layer moves its important declarations down in importance priority. That is usually the moment to remove the !important entirely, which the layer order has now made unnecessary.
Two teams extract overlapping components at once.
: Both land rules for shared selectors in components, and source order between their files decides. Assign sub-layers per team before parallel extraction starts — see layer naming conventions and governance.
Complete working example
/* ============================================================
One full iteration for the card component
============================================================ */
@layer legacy, reset, base, theme, components, utilities;
@import url("/css/app.css") layer(legacy);
/* ── COMMIT 1: verbatim move. Values untouched, weight untouched. ──────── */
@layer components {
.card { border: 1px solid #d4c89a; padding: 24px; background: #fffdf5; }
#main .page-pricing .card { padding: 32px; }
.card .card__title { font-size: 18px; font-weight: 600; }
.card > .card__body p:last-child { margin-bottom: 0; }
}
/* ── COMMIT 2 (after a clean parallel run): simplify + tokenise. ────────
WHY separate: commit 1 proved the layer move was safe. Any regression
from here is attributable to the rewrite, not to the migration. */
@layer components {
.card {
border: 1px solid var(--color-border);
padding: var(--space-6);
background: var(--color-surface);
}
/* The ID and page scope are gone — source order inside the layer already
decides between two .card rules. */
.card--roomy { padding: var(--space-8); }
.card__title { font-size: var(--font-size-md); font-weight: 600; }
/* WHY :last-child stays: it expresses a real structural condition, unlike
the specificity padding that was removed. */
.card__body > :last-child { margin-block-end: 0; }
}
/* ── COMMIT 3: delete the .card rules from legacy/app.css. ──────────────
The layer coverage metric moves, and the component is fully migrated. */Frequently Asked Questions
Why copy rules verbatim instead of rewriting them during extraction?
Because a verbatim move has exactly one variable. If the component renders differently afterwards, the layer change caused it, and the diagnosis takes a minute. A commit that moves and rewrites has two candidate causes, and separating them means bisecting a change you already shipped. The rewrite is worth doing — just not in the same commit.
How do you find every rule belonging to a component?
Start with a text search on the class prefix, then match against a live element through the CSSOM. The second pass is what catches the overrides written years later in unrelated files — page-scoped selectors, ID-qualified variants, and rules whose selectors mention the component only through a descendant combinator. Those are exactly the rules that break a page after an incomplete extraction.
When is it safe to delete the legacy copy?
After one release cycle where the extracted rules have been winning in production with no reports. You can also delete in the same commit as the extraction — that is a legitimate, faster style — but you give up the property that makes the parallel run attractive: rollback is deleting one new file rather than reverting a change to the shared legacy bundle.
Related
- Wrapping Legacy CSS in a Single Legacy Layer — the beachhead this loop runs on top of
- Measuring Migration Progress With a Layer Coverage Report — the metric each iteration moves
- Step-by-Step Specificity Audit for Legacy Projects — how to plan the extraction order
- Flattening Specificity With :where() in Layers — the tool for the simplify commit
Up: Incremental Migration Strategies → Browser Support, Compatibility & Migration