How to Refactor Legacy CSS Incrementally on a Live Production Site

Refactor legacy CSS incrementally on a live site using cascade layers, coverage audits, and component-scoped custom properties without a full rewrite.

You are refactoring legacy CSS incrementally on a live production site. Stop pretending a rewrite is possible. A big-bang rewrite of a stylesheet that has accreted over four years is how production goes dark on a Tuesday. What works instead is a migration that lets old and new CSS coexist, where the cascade does the heavy lifting and no rule ever needs higher weight to win. The method has a name, and it is the only sane way to ship changes while the site keeps shipping. Wrap the legacy CSS in a cascade layer. Write new styles in ordered layers above it. Let the browser resolve conflicts by layer priority rather than by specificity. That is the entire trick. It protects you from three failures: the specificity war that makes every new rule require an even longer selector, dead CSS nobody can prove is safe to delete, and a rename nobody dares do because touching a single class name might collapse the page. Refactoring legacy CSS incrementally means you never have to make the jump all at once.

Run A Coverage Audit Before You Touch A Rule

Before you touch a single rule, run a coverage audit. You need to know what is actually used and what is only believed to be used. A coverage audit is not a feeling. It is not a grep for a class name in a template file. It is a tool run against the real pages your users load, with a browser that records which selectors match. PurgeCSS and UnCSS are the names you will hear. They work. They also fail in two predictable ways. First, if your content glob misses dynamically generated class names from a JavaScript framework, the purge deletes styles that are alive and the page goes unstyled in production. Second, if you purge against a static HTML snapshot of a single-page application, you lose every state that is not in that snapshot: the open modal, the hover state, the error message that only appears after a failed fetch. The safe sequence is: instrument the live site, collect coverage data over a full user session, then purge only what the data proves unreachable. Anything you cannot prove dead stays alive, wrapped in a layer, until you can prove it later.

Every Style Must Live In A Layer

The core of the method is the cascade layer. Understand one rule: unlayered styles always beat layered styles, regardless of specificity. That rule makes the migration possible. It is also the trap that catches everyone who tries it halfway. If you wrap your legacy CSS in a @layer legacy block but leave one stray rule outside, that stray rule wins every conflict against your new layered styles, no matter how low its weight. The fix is to put everything in a layer. The legacy code goes in @layer legacy. Your new styles go in ordered layers above it. Declare the order once, at the top of the file, before any layered rules appear.

A mistake people make is declaring layer order in one file and expecting it to apply globally when the @layer statement loads after a third-party stylesheet has already injected unlayered rules. Third-party libraries are the other trap: if a library loads its CSS after your @layer statement and does not use layers, its unlayered styles outrank your layered ones. Wrap the library in a layer too, or accept the override and write your new styles with the library's specificity in mind.

What you are protecting against How the layer method fixes it What the old way did instead
Specificity war: every new rule needs a longer selector Layer priority beats specificity; (0,1,0) in a higher layer beats (0,3,0) in a lower layer Added #id or !important to win, making the next rule need even more
Dead CSS nobody can prove safe to delete Coverage audit identifies unreachable rules; layers let you keep the uncertain ones isolated Guesswork, or a purge that deletes live styles for a missing state
A rename nobody dares do New layered styles override legacy in the same layer order; you can rename a class and ship it with a fallback Fork the class, keep the old one, forget which is which
Third-party styles fighting your overrides Wrap the library in a layer or place your layers after it A specificity arms race against the vendor’s selector depth
Source order dependency: move a rule and the page breaks Layer order is declared once, so source order within a layer no longer matters You reordered rules by hand and prayed

Here is the same component styled two ways. The legacy version uses a deeply nested selector that only wins because it is specific enough to beat everything else on the page. The refactored version uses @layer and a lower specificity, and it wins because it sits in a higher layer. This is the before/after pair that demonstrates the whole method. The component is a card with a title, a body, and a button. In the legacy stylesheet, the card’s title is styled by a selector that reaches through four levels of DOM nesting. In the layered version, a single class selector does the same job.

/* Legacy: specificity (0,4,0) */
.product-grid .product-card .product-card__header .product-card__title {
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1.2;
}

/* Refactored: specificity (0,1,0), but wins by layer order */
@layer base, components, legacy;

@layer components {
  .card-title {
    font-size: 1.25rem;
    font-weight: 700;
    line-height: 1.2;
  }
}

@layer legacy {
  .product-grid .product-card .product-card__header .product-card__title {
    /* same declarations, now deprioritised */
  }
}

What changed is not the visual result. The computed value is identical. What changed is the specificity graph. The legacy selector has a specificity of (0,4,0), which means any new rule that needs to override it must be at least (0,5,0) or use !important, and the next team that touches this code inherits that arms race. The refactored selector has (0,1,0), and it wins because it lives in the components layer, which is declared before legacy. The layer order is the priority, not the selector depth. The old selector still exists, still matches, still applies, but it cannot beat the new rule because the new rule is in a higher layer. This is the incremental refactoring pattern in miniature: you never delete the legacy rule, you just demote it.

The second before/after pair shows how you handle a rename that nobody dares do. The legacy class is .btn-primary, and it has been styled in six places across three stylesheets, each with a slightly different specificity. Renaming it to .button–primary would normally require touching every file and hoping you did not miss a template. With layers, ship the rename in one release and let the cascade reconcile the two. Put the legacy selector into the legacy layer. Put the new selector into the components layer. The new one wins because of layer order, not because you edited six files. Keep the old class in the DOM for a transition period, then remove it in a later cleanup once the coverage audit shows it is dead.

/* Legacy: .btn-primary styled in three places with rising specificity */
.btn-primary {
  background-color: #0055ff;
}
.card .btn-primary {
  background-color: #0044cc;
}
.modal .card .btn-primary {
  background-color: #003399;
}

/* Refactored: one class, one layer, no specificity escalation */
@layer base, components, legacy;

@layer components {
  .button--primary {
    background-color: #0055ff;
  }
}

@layer legacy {
  .btn-primary,
  .card .btn-primary,
  .modal .card .btn-primary {
    background-color: #0055ff;
  }
}

The legacy stack had three selectors because each new context needed a more specific selector to beat the previous one. That is the specificity war in action. The refactored version has one selector with (0,1,0) weight, and it wins because it is in the components layer. The three legacy selectors are collapsed into one rule in the legacy layer, where they cannot outrank the new rule. The page renders the same, but the specificity graph is flat. The next developer who needs to change the button’s background colour writes one rule, not three.

The third before/after pair is about dead CSS. The legacy codebase has a utility class .clearfix that was used in 2012 and has not matched a single element since 2019. Nobody has deleted it because deleting it might break something. The coverage audit proves it is dead. The refactored version removes it entirely and replaces it with a modern layout technique that does not need a clearfix because the formatting context is handled by the layout engine. This is the case where the layer method is not even necessary; the audit is the tool. But the layer method is what lets you keep the uncertain ones while you gather proof.

/* Legacy: a clearfix that matches nothing, but nobody will delete it */
.clearfix::after {
  content: "";
  display: table;
  clear: both;
}

/* Refactored: no clearfix needed, the grid handles the formatting context */
@layer base, components, legacy;

@layer base {
  .row {
    display: grid;
    grid-template-columns: repeat(12, 1fr);
  }
}

@layer legacy {
  .clearfix::after {
    /* kept for now, but the coverage audit says it matches nothing */
  }
}

What changed is that the layout no longer depends on the clearfix because the grid formatting context contains the floats internally. Keep the legacy rule in the legacy layer for one release cycle, then delete it after the audit confirms zero matches in production traffic. The mistake people make here is purging against a static snapshot instead of live data. A static snapshot of the homepage will not show the admin panel class that only appears after login. The live audit will. If you cannot run a live audit, keep the rule in the legacy layer and move on.

Scope And Design Tokens Complete The Architecture

The modern CSS architecture migration is not just about layers. It is also about scope. @scope lets you limit a selector's reach to a DOM subtree without increasing specificity. This is the tool for component isolation. It answers the question of why your card styles leak into the footer. The legacy way was to write a selector with enough weight to win everywhere but hope it did not match anything outside the component. The modern way is to declare a scope root and let the browser enforce the boundary. The failure mode with @scope is using it to increase specificity instead of to limit reach, which recreates the specificity war under a new name. Use @scope to say "these styles apply inside this subtree", not to say "these styles beat everything."

Another piece of the migration is design tokens. Custom properties are the mechanism. They beat preprocessor variables because they inherit, cascade, and update at runtime. A preprocessor variable is baked at build time; a custom property is resolved at computed value time. That distinction matters for theming. If you want a dark mode toggle, you cannot do it with a Sass variable that was compiled in 2024. You can do it with a custom property that flips on the html element. The migration path: replace hardcoded color values with custom properties, one at a time, and register them with @property so they get type safety. The mistake with @property is declaring a syntax that does not match the initial value, which makes the property invalid at parse time and silently breaks everything that uses it.

Get The Layer Order Right In Production

The cascade layer migration strategy has a specific ordering problem. The @layer statement must be loaded before any layered rules in all stylesheets. If you declare layer order in main.css but a component stylesheet loaded later declares its own @layer statement, the browser uses the first declaration it sees. This is a source order issue that only appears in production, not in local development, because local development loads files in a different order. The fix: put all layer order declarations in a single file that loads first. Never repeat @layer statements elsewhere. A common mistake is nesting @import layer() statements inside @layer blocks, which changes the layer order compared to declaring them at the top of the file. Keep the top-level @layer statement clean and put all imports at the top.

Flatten The Specificity Graph For Good

The specificity war prevention is the single biggest win. Once you have layers, you never need to write a selector that is more specific than the one before it. The layer priority is explicit, declared once, and it overrides specificity within a layer. A declaration with weight (0,1,0) in a higher layer beats (0,3,0) in a lower layer. This is the opposite of the unlayered cascade, where specificity decides everything. The practical effect: your specificity graph flattens over time. Old selectors stay in the legacy layer and stop competing. New selectors stay at (0,1,0) or (0,2,0) and never escalate. Use a specificity heatmap from a tool like CSS Stats or Project Wallace to see where the escalation is happening before you refactor.

Remove Dead CSS With A Two-Step Safety Check

Removing dead CSS safely is a two-step process, and the second step is the one most people skip. Step one: the coverage audit that proves a rule matches nothing. Step two: the regression test that proves removing it does not change the rendered page. A visual regression testing suite, like Percy or BackstopJS, takes screenshots before and after the removal and diffs them. If the diff is empty, the rule was dead. If the diff shows a change, the rule was alive in a context your audit missed. The mistake is skipping the second step because the audit looked clean. Visual regression testing is not optional for a live production site. It is the only way to be sure that deleting a selector did not change a computed value somewhere you are not looking.

Handle Third-Party Libraries And Unlayered Traps

A common failure case: layer order not being respected because unlayered styles from a third-party library loaded after your @layer statement. The library's unlayered styles have the highest priority, above all your layers, regardless of specificity. This is not a bug. It is the spec. Unlayered styles always beat layered styles. The fix: wrap the library's stylesheet in a layer of its own, or place your layers after it in source order. If you cannot wrap the library because it is loaded from a CDN, use @import url("library.css") layer(library) at the top of your file, which puts the library in a named layer. The other failure case: the @layer statement itself loading after the library. Your layers are declared after the library's unlayered styles, and the library still wins because it is unlayered.

Ensure A Graceful Fallback For Older Browsers

The question of whether cascade layers are safe to use in production is answered by the @supports fallback. Browsers that do not support @layer will ignore the @layer statement and parse the rules inside it as unlayered styles. The fallback is automatic: the legacy styles apply normally, the new layered styles apply normally, and there is no layering, so the specificity war continues in those old browsers. The fallback pattern is @supports not (@layer) { /* unlayered styles */ }, but you rarely need it because the layer syntax is designed to degrade gracefully. The more important check: do not accidentally put unlayered styles outside any @layer block. Those will have the highest priority and defeat the purpose of layering.

Explain The Architecture To Stakeholders

The design-system author defending this architecture to stakeholders needs the precise vocabulary. Cascade layers are priority buckets that sit between origins and specificity. A rule inside @layer base is lower priority than a rule inside @layer components, regardless of specificity. This is not a hack; it is the formalisation of what source order always did, made explicit. The stacking context and the containing block are not affected by layers; those are separate mechanisms. The inheritance of custom properties is separate too: a custom property set on a parent inherits to children unless a child sets its own value, and that inheritance crosses @scope boundaries. A scoped component can still inherit a design token from outside its scope. The term to use is "computed value stage": custom properties resolve at computed value time, which is why they can respond to runtime changes.

Check Your Build Tool Does Not Strip Layers

If you are using Lightning CSS as your build tool, the layer syntax is preserved and the output is optimised. Lightning CSS does not transpile @layer to an older form because there is no older form that works. It minifies the layer declaration and deduplicates rules. The practical benefit: write the layer statement once and let the build handle the rest. A mistake people make is using a tool that strips layers because it does not understand them, which silently reverts your specificity protection. Check the output of your build for @layer after every change. If it is gone, your tool is breaking your architecture.

The Honest Caveat

The honest caveat about this method: it does not fix a codebase that has no tests. If you are refactoring legacy CSS incrementally without a visual regression suite, you are flying blind, and the method will not save you. Layers protect you from specificity wars, but they do not protect you from a selector that matches the wrong element because your DOM structure is ambiguous. The coverage audit protects you from deleting live styles, but it does not protect you from a style that is live in a context you did not instrument. The method is a structure, not a guarantee. You still need to look at the page, run the tests, and check the diffs. The one sentence that could not appear on another page about this subject is this: the only safe way to refactor legacy CSS incrementally is to wrap every unlayered style in a @layer legacy block and then never write a selector more specific than (0,2,0) again.