Migrating Away from a CSS Framework: Audit, Extract, and Replace Without a Rewrite

A step-by-step strategy for migrating away from a CSS framework: audit dead code, extract to native declarations, and use @layer to replace without a rewrite.

At 2:47 PM on a Thursday, the production site went down. Not the whole site, just the checkout button. A teammate had removed one utility class from a template, the framework’s purge step dropped the rule that made it visible, and the button vanished into the background. The fix took 90 seconds: re-add the class, rebuild, deploy. But the incident cost the team a morning of post-mortem, and it was the third one that month. That is the moment you start planning a migration away from a CSS framework, not because the framework is bad, but because the bill comes due in shipped bytes and invisible dependencies. This playbook walks you through an audit-first, extract-and-replace exit that avoids a rewrite, with the time and risk of each step quantified so you can defend the plan to a stakeholder who asks “how long?”

The Coverage Audit

Start with a coverage audit, which answers a question you have probably never asked: what percentage of the CSS you ship actually matches a node in the DOM? Boot Chrome DevTools, press Command+Shift+P, type "Coverage", and start instrumenting. Reload every route, click every accordion, open every modal. The panel reports bytes and percentages per stylesheet. On a typical Bootstrap 5 site, the number is brutal: 80% or more of the framework payload is unused. Tailwind's purge reduces that, but only if you configured safelists correctly, and the safelist itself often keeps dead classes alive. The audit takes 2 to 4 hours for a medium site with 50 routes. The output is a list of selectors that never match, which is the raw material for the extraction phase. Run the audit before you touch a single line of code; without it, you are removing blindfolded.

Migrating Away from CSS Framework Strategy: What It Costs and Where It Breaks

Four Phases, Each With a Price Tag

The strategy has four phases. Phase one, the audit above, costs 2-4 hours of engineering time and zero risk, you are only reading. Phase two, token extraction, costs 4-8 hours and introduces the first real risk: if you change a custom property value and the cascade picks it up in a different origin, you get a visual regression. Phase three, component replacement, is the long tail: 20-40 hours for a typical site, replacing utility classes with co-located styles. Phase four, framework removal, is a 30-minute job that is surprisingly risky because it is binary, the link tag goes, and every missed dependency collapses at once. The total is roughly 30-50 hours for a small team, and the risk peaks in phase four, not phase one.

The mitigation: never remove the framework import until the coverage audit reports zero matched selectors from the framework's own output. That is a number you can chase.

Removing Tailwind From Production Site: The Purge Trap and the Class Audit

Tailwind is a different beast because its dead code elimination is automatic, the purge step scans your templates and keeps only the classes it sees. The trap is that the scan is content-based, not DOM-based. It sees class="btn btn-primary" in a template string and keeps both classes, even if a JavaScript conditional never renders that combination. The result is a shipped file that is smaller than Bootstrap's but still carries dead weight.

Run a Live-DOM Class Audit

To audit a Tailwind production site, run a Puppeteer or Playwright crawl of all routes, extract the computed class attribute values from the live DOM, and diff that list against the framework's utility class list. Flag classes with zero occurrences. This crawl takes 1-2 hours to script and run, and it catches the classes that the purge kept because they appeared in a string but never in a rendered element. The cost of skipping this step is a false sense of completion: you remove the Tailwind import, and a class that appeared in a live path but was never rendered is still referenced, and the layout breaks.

The extraction step is where you replace a framework utility class with the native CSS declaration it generated. Do not rebuild the utility class one-to-one in your own stylesheet, that merely moves the dead code. Instead, co-locate the style with the component. Here is the pattern, verified by comparing computed styles before and after:

/* Before: Tailwind utility class in the HTML */
/* <div class="p-4">...</div> */

/* After: co-located style in the component CSS */
.component {
  padding: 1rem; /* Tailwind's p-4 resolves to 1rem in default config */
}

The computed style check is non-negotiable. Open DevTools, select the element, read the padding value in the computed pane, write it down, apply your custom rule, and re-read. If the numbers match to the pixel, the swap is safe. If they do not, the difference is usually a custom property or a layer order issue, the framework’s rule was winning because it was unlayered and yours is layered. That is the moment to use @layer, not to add !important.

Bootstrap Migration to Native CSS: From 12-Column Grids to CSS Grid

Bootstrap's grid is a 12-column float-based system, and it is the single biggest chunk of dead weight on most Bootstrap sites. The swap is CSS Grid, which has been Baseline for years and does the same job in a fraction of the bytes. The migration is mechanical: find every class like `col-md-6` and replace it with a grid declaration on the parent. Here is the pattern:

/* Before: Bootstrap 5 grid classes */
/* <div class="row"><div class="col-md-6">...</div><div class="col-md-6">...</div></div> */

/* After: native CSS Grid */
.row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

The gap property is the hidden win. Bootstrap used padding on columns and negative margins on rows to simulate gutters, and that technique produces horizontal scrollbars when a child overflows. CSS Grid’s gap handles it natively. The cost of this step is 2-4 hours per page template, and the risk is the fallback: if you have users on a browser without Grid support, you need a float-based fallback inside an @supports block. Check your analytics for iOS Safari versions locked to device; if the oldest is from 2019 or earlier, you are clear. For everyone else, consult caniuse for current support data.

Extracting Bootstrap Components

Bootstrap's component classes, `btn`, `card`, `navbar`, `modal`, are the next target. Each one is a bundle of 10-30 declarations, and most sites use a fraction of them. The extraction rule: for each component, write a custom class that contains only the declarations that the component actually needs on your site, and drop the rest. A Bootstrap `btn` is roughly 20 declarations including hover, focus, and disabled states; your custom button might need 8. The dead code elimination here is manual, but the coverage audit tells you which components to prioritize, the ones with the highest byte count and the lowest usage frequency. The shipped bytes drop is real: Bootstrap 5's full CSS is ~22 KB gzipped, and a typical site with a handful of components and a custom grid can cut that to 3-5 KB gzipped by the time you finish. That is a 75-85% reduction, and it is all dead weight that was slowing down first paint on mobile connections.

CSS Framework Dead Code Audit Extraction: The Specificity Graph and the @layer Escape Hatch

Why @layer Changes Everything

The audit tells you what is dead, but the extraction needs a mechanism to replace the framework's styles without fighting the cascade. That mechanism is `@layer`, and it is the escape hatch that makes the whole migration incremental rather than all-or-nothing. The pattern is to place the entire framework in a named low-priority layer, then build your replacement styles in a higher-priority layer. Because cascade layers override specificity, your replacement wins even when the framework selector is more specific. Here is the setup:

/* Step 1: put the framework in a named layer */
@layer framework {
  @import "bootstrap.css";
}

/* Step 2: build replacements in a higher layer */
@layer app {
  .btn {
    padding: 0.5rem 1rem;
  }
}

This is the escape hatch because it lets you ship both the framework and your replacement styles simultaneously. The browser resolves the cascade: unlayered styles beat layered ones, and @layer app beats @layer framework because it appears later in the layer list. The cost is a temporary increase in shipped bytes, you are shipping the full framework plus your replacements, but that increase is bounded. For a Bootstrap site with 22 KB gzipped framework, adding 5 KB of replacements brings the total to 27 KB during the transition. That is a 5 KB increase for a few weeks, and it is the price of not doing a risky big-bang rewrite. The alternative, removing the framework import before all replacements are in place, is the mistake that collapses layouts and sends you back to the starting line.

Reading the Specificity Graph

The specificity graph is the map of what you are fighting. Before layers, the cascade ordered rules by origin, specificity, and source order. A framework like Bootstrap 5 uses class selectors (specificity 0,1,0), and a utility class like `.p-4` also uses a class selector, so the last one in the source order wins. That is why Tailwind's utilities are generated after components. With `@layer`, you replace that fragile source-order dependency with an explicit priority declaration. The rule to remember: specificity within a layer does not escape the layer. A rule in `@layer app` with specificity 0,2,0 beats a rule in `@layer framework` with specificity 0,3,0, because the layer order takes precedence. This is the formalisation of what was always true, order-of-appearance was the real arbiter, and layers just make it explicit. The practical effect: you can write simpler selectors in your replacements, because you no longer need to match the framework's specificity arm-for-arm.

@layer Migration Pattern Framework Removal: When the Coverage Reaches Zero

The final step is removal, and it has a crisp trigger condition: the coverage audit reports zero matched selectors from the framework's layer. That means every rule the framework shipped is either unused (dead code) or replaced by your own. When that number hits zero, you can delete the `@layer framework` block and the import inside it. The removal is a 30-minute job: delete the block, rebuild, run the coverage audit again to confirm zero, and deploy.

The risk is that the audit was incomplete. If you missed a route or a dynamically-rendered condition, the removal breaks that page. The mitigation: keep the framework import in place for one full sprint after the zero is first recorded, and run the audit at the end of the sprint to confirm the zero holds across new code. The cost of this discipline is a few weeks of shipping an extra 22 KB gzipped on a site that could be 5 KB, but the cost of a broken production page is higher by orders of magnitude.

Common @layer Failure Modes

During the transition, you will hit the failure modes that are specific to `@layer`. The most common is placing `@layer` declarations after `@import` rules that inject styles into unlayered positions. If a stylesheet is imported without a layer, its rules are unlayered, and unlayered rules beat layered ones regardless of order. The fix is to wrap every framework import in the named layer, which means you cannot use the plain `@import` at the top of your file, you must put it inside the `@layer` block. Another failure: the fallback for browsers that do not support `@layer`. Use an `@supports (at-rule(@layer))` guard, and ship the full framework unlayered in the fallback. The guard syntax ensures that the migration is progressive, modern browsers get the layered cascade, old browsers get the framework intact. Check your analytics for the actual browser spread before adding the fallback.

Design Tokens to CSS Custom Properties: The First Extraction, Always

Before you touch a single utility class or grid column, extract the design tokens. Bootstrap's variables, `$primary`, `$font-size-base`, `$spacer`, are preprocessor variables, frozen at build time. Replace them with CSS custom properties, which cascade, inherit, and update at runtime. This is the foundation of every other swap, because your co-located styles will reference these tokens, and the framework's own rules can also consume them if you override the framework's variables with custom properties. Here is the pattern:

:root {
  --color-primary: #0d6efd;
  --spacer: 1rem;
  --font-size-base: 1rem;
}

/* Replace Bootstrap's $primary with the custom property */
.btn-primary {
  background-color: var(--color-primary);
}

The cost is 1-2 hours to map the framework’s variables to custom properties, and the risk is nearly zero because you are not changing values, only the mechanism. The win: custom properties update at runtime, which means you can implement theming with a single attribute change on the html element, something preprocessor variables cannot do. The failure mode is the typo in the var() fallback syntax: var(--color-primay, #0d6efd) silently uses the fallback if the property name is misspelled, and the visual regression is subtle. The guard is @supports (--foo: 0) to test custom property support.

Selector Bloat and Tree-Shaking: What the Purge Misses

The coverage audit catches unused selectors, but it does not catch selector bloat, rules that match but carry more specificity than they need. A framework like Bootstrap generates selectors like `.card > .card-body > .card-title`, which is specificity 0,3,0 and forces your replacements to match or exceed it. When you extract, you have the chance to simplify: `.card-title` is specificity 0,1,0, and with `@layer` it wins anyway.

The simplification has a measurable effect on shipped bytes and parse time. The browser must parse every selector in the stylesheet, and a selector like `.card > .card-body > .card-title` is longer than `.card-title` by roughly 25 characters. Across a large stylesheet, that adds up to kilobytes of source that compress under gzip, but the parse cost is real on low-end devices. The tree-shaking that the framework does is content-based; it removes unused classes, but it does not simplify the selectors of the classes it keeps. That is your job, and it is the difference between a migration that cuts bytes and one that merely moves them.

What It Costs in Shipped Bytes: The Temporary Increase Is the Price of Safety

Here is the number stakeholders will ask for. Bootstrap 5's full CSS is ~22 KB gzipped. Tailwind's output varies with purge: a minimal site might ship 3 KB, a complex one much more. During the `@layer` transition, you ship the framework plus your replacements, so the total is framework + replacement. For a Bootstrap site with 5 KB of replacements, that is 27 KB gzipped, a 5 KB increase over the 22 KB baseline. But the replacement styles are the ones you keep, and when the framework layer is removed, the total drops to 5 KB. The net reduction is 17 KB gzipped per page, which is 77% of the framework payload. On a site with 1 million pageviews per month, that is 17 GB of bandwidth saved per month, at a cost of roughly $1.50 per GB on a typical CDN, about $25 per month. The temporary increase costs a few cents. The permanent reduction pays for itself in a week. The real cost is the engineering hours, and that is the number to put on the slide: 30-50 hours for a small team, one-time, with the risk concentrated in the final removal step.

FAQ: Five Questions Before You Start

How long does the migration take? For a medium site with 50 routes, plan on 30-50 hours of engineering time spread over 2-4 sprints. The audit is 2-4 hours, token extraction is 4-8 hours, component replacement is 20-40 hours, and removal is 30 minutes plus a validation sprint.

Can I skip the coverage audit and remove the framework? No. The audit is the map. Without it, you are removing blindfolded, and the first missed dependency will collapse a layout in production. The audit costs 2-4 hours and zero risk; skipping it costs an incident.

What if a browser does not support @layer? Use the `@supports (at-rule(@layer))` guard. In the fallback, ship the framework unlayered and your replacements unlayered, ordered so that the replacements come last. The fallback is for legacy enterprise browsers that are locked to device; check your analytics before writing it.

Is the temporary byte increase worth it? Yes, because it is bounded. The increase is the size of your replacement styles, typically 5-10 KB gzipped, and it lasts only until the framework layer is removed. The alternative, a big-bang rewrite, risks a multi-day outage and a rollback.

What is the most common mistake? Removing the framework link or import before all dependent classes are replaced. The coverage audit reports zero, but the audit is only as good as the crawl. If you missed a route, the removal breaks it. Wait one full sprint after zero is recorded, and re-run the audit before deleting the layer.

The Honest Caveat: The Migration Is Never Really Finished

The migration away from a CSS framework is not a project with a completion date. It is a discipline: every new component you write must be co-located and custom-property-driven, every old component you touch must be extracted, and the coverage audit is a quarterly ritual, not a one-time event. The framework you removed will be replaced by a temptation to adopt the next one, there is always a newer utility-first library with a better build step. The honest truth is that the framework was never the problem; the problem was that it shipped 22 KB for 2 KB of value on your site. The discipline that gets you out is the discipline that keeps you out. The day you stop running the audit is the day the dead code starts accumulating again, and 18 months later you are having the same conversation about a different dependency. That is the cost of the escape hatch: it is not a door that closes behind you, it is a ladder you have to keep folded and ready.