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.

The per-component extraction loop A five-step cycle. Collect every rule, copy it verbatim into the target layer, deploy a parallel run where both copies exist, simplify the now-redundant specificity, then delete the legacy originals and return to the start for the next component. 1 · collect every matching rule 2 · copy verbatim, no edits 3 · parallel run both copies live 4 · simplify flatten selectors 5 · delete legacy copy next component One thing per commit: step 2 changes the layer, step 4 changes the selectors. Never both at once — a regression is then always attributable to a single cause.

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.

What layer order makes redundant Two selector examples. The legacy version uses an ID plus a page class plus the component class to win a conflict. The migrated version uses a single modifier class, because layer order and source order already decide the outcome. In the legacy layer #main .page-pricing .card specificity 1-2-1 — weight bought purely to beat a sibling rule Every later override had to exceed 1-2-1, so the ratchet only turned one way. After extraction .card--roomy specificity 0-1-0 — the layer and source order decide the conflict The next override needs no extra weight at all — it just goes in a higher layer. Do this in its own commit — mixing it with the move makes a regression ambiguous.

Verification

  1. Inspect the component. Its winning rules should be annotated @layer components, with the legacy copies struck through beneath them.
  2. 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.
  3. Check the page-scoped variants specifically. They are the rules most likely to have been missed in Step 1.
  4. 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 commit per concern Three commits per extraction, each with a single concern: move the rules verbatim, simplify the now-redundant selectors, then delete the legacy originals. commit 1 — move verbatim the only variable is the layer commit 2 — simplify selectors the weight the layer order made redundant commit 3 — delete the originals the coverage metric moves here
/* ============================================================
   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.

Up: Incremental Migration StrategiesBrowser Support, Compatibility & Migration