Use case · Tailwind removal

From utility soup to a design system the next developer can read

We extract every used utility into a token, group every recurring class string into a named component, and rewrite the JSX so each line says what it is, not how it is painted. Dark mode runs off `[data-theme]` tokens. The build ships a CSS file under 30 KB. The codemod proves nothing visual changed.

The problem

Tailwind buys speed at the start and charges interest forever

The same project that shipped its first screen in a weekend is, two years later, a wall of `flex items-center justify-between gap-3 px-4 py-2 rounded-md bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors`. Six of those strings differ by a single token. None of them have a name. The designer ships a new spacing value and the team grep-replaces 240 files. A theme refresh is a sprint, not a Friday. We close that gap by reading the codebase, mapping what is actually used, promoting recurring patterns to named components, and replacing the utility strings with classes that mean something. The output is a CSS layer under 30 KB, a token file the designer can change in one place, and JSX that explains itself without a Tailwind cheatsheet.

Our approach

Six steps from a Tailwind codebase to a typed design system

The order is non-negotiable. Audit before extract, extract before name, name before rewrite, rewrite before delete. Removing Tailwind before the design system is in place produces a worse codebase than the one we started with.

  1. 01

    Audit the utility footprint

    We run a static pass that lists every Tailwind class used in the repo, with frequency. The output is a CSV: 1,847 classes total, 312 unique, the top 30 cover 84% of usage. The audit splits the long tail (one-off classes, usually a sign of design debt) from the head (the patterns that have already become components in everything but name). The CSV is the brief for the next steps; nothing else gets done without it.

  2. 02

    Extract the tokens

    Every color, spacing value, font size, radius, and shadow already in `tailwind.config.ts` becomes a CSS custom property. The naming follows the semantic the design system asks for, not the Tailwind palette name. `bg-zinc-900` does not become `--bg-zinc-900`; it becomes `--color-bg` (light: white; dark: zinc-900). The first sign the work is on track is that color tokens read like meanings, not like swatches.

  3. 03

    Promote recurring strings to named components

    Each top-30 utility string that appears 10 or more times in the codebase becomes a named component or BEM class. `flex items-center justify-between gap-3 px-4 py-2 rounded-md ...` becomes `<ListRow>` or `.list-row`. The recurring string is rewritten exactly once; everywhere else, the component renders it. Each promotion is a PR with a screenshot diff and a count of replacements.

  4. 04

    Wire dark mode through tokens

    Tailwind expresses dark mode by repeating every line with a `dark:` prefix. The new system expresses it once: each token has a light and a dark value, gated by `[data-theme]`. JSX stops carrying theme information at all. The components do not know whether they are rendering light or dark. The toggle is two lines in a layout component.

  5. 05

    Run the codemod

    A scripted pass rewrites every JSX file: utility strings replaced by the named class, dark variants removed (handled by tokens), arbitrary values flagged for human review. The codemod ships in batches of 30 to 60 files, each with a visual regression run. The PR description includes the file count, the class delta, and the bundle size delta. Nothing visual changes between the before and after screenshots.

  6. 06

    Delete Tailwind

    Once every JSX file has migrated and the visual regression suite is green, `tailwind` and its plugins come out of `package.json`. `tailwind.config.ts` is removed. The PostCSS pipeline drops one plugin. The final CSS bundle reports its true size: usually under 30 KB for a real app, down from 70 to 200 KB of pre-purge Tailwind. The team ships the same product on a third of the CSS.

What we deliver

Class usage audit

A CSV listing every Tailwind class used in the repo, with frequency, file count, and a tag for `head`, `mid`, or `tail`. The artefact that drives the rest of the migration. Re-runnable; the codemod uses it as input.

Token extraction map

A markdown table mapping every used Tailwind token to the new semantic CSS variable. The designer signs off on this document before the rewrite starts; once signed, the rewrite is mechanical.

Component promotion log

One row per promoted pattern: the original utility string, the new component or BEM class name, the file count, and a screenshot diff. The log is the audit trail when a developer six months later asks why a particular class exists.

Token CSS file

A single CSS file containing every `--color-*`, `--space-*`, `--radius-*`, `--shadow-*`, `--font-*` variable, with light and dark values gated by `[data-theme]`. Replaces `tailwind.config.ts` as the source of design truth. Versioned.

BEM layer

A CSS layer with the named classes that absorb the recurring utility strings. The file is hand-written, indented, commented. No PostCSS magic; nothing generated. The next developer can read it like a stylesheet, not a build artefact.

Codemod scripts

Two Node scripts: one rewrites JSX utility strings into named classes; the other removes `dark:` variants and replaces them with theme-aware tokens. Both ship in the repo for future use; both produce diffs reviewable by a human.

Visual regression baselines

A Playwright suite that screenshots every key route in light and dark mode before the migration starts. The same suite runs after each batch; the migration passes only when the diff is zero. Drift becomes a build error, not a release-day surprise.

Dark mode pattern doc

A short README explaining how dark mode works in the new system, with three examples (a button, a card, a chart). Replaces the implicit Tailwind `dark:` mental model. The document the next developer reads on day one.

Bundle size report

A before-and-after CSS bundle size diff, broken down by file. Pre-migration Tailwind plus its plugins; post-migration the token file plus the BEM layer. The number that justifies the migration to the founder who funded it.

Storybook coverage

A Storybook entry for every promoted component, with controls for variants and theme. Designers click through the components against the staging URL. Replaces the `flex items-center px-4` paragraph in the team chat with a permalink.

ESLint and Stylelint rules

Lint rules that block new Tailwind utilities being added to the JSX after the migration ships. The rules read the token CSS as ground truth; arbitrary pixels and hex values fail the build. The migration stays migrated.

Migration runbook

A README in the repo describing how a future codebase change uses tokens and BEM instead of utilities. Where to add a new token, how to name a new class, when to ask the designer for a new value versus reusing an existing one. The doc that survives the next team rotation.

Five files that take a Tailwind codebase to a typed design system

The five files below compose the migration pipeline. The class extractor that reads the repo and emits the usage CSV, the token transformer that writes the CSS variables, the codemod that rewrites JSX into named classes, the dark-mode bridge that retires every `dark:` prefix, and the lint rule that prevents Tailwind from creeping back in.

A Tailwind removal is a refactor problem before it is a CSS problem. The repo already encodes a design system; it just expresses every component as a string of utility classes instead of a name. Our job is to read what is there, give the recurring patterns names, and rewrite the JSX so each line says what the element is. The pixels stay the same. The code becomes readable.

The five files below compose the migration pipeline. The class extractor that produces the audit, the token transformer that writes the CSS variables, the codemod that rewrites the JSX, the dark-mode bridge that retires the dark: prefix, and the lint rule that holds the line.

1. The class extractor

We read every .tsx and .jsx file in the repo, pull out every className literal, and tally the utility classes. The output is a CSV the rest of the pipeline consumes. The extractor uses a small AST pass (not a regex) because Tailwind classes appear in template literals, cn() calls, and conditional expressions, and a regex misses the second and third forms.

// scripts/tailwind/extract-classes.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'
import { parse } from '@typescript-eslint/parser'
import type { TSESTree } from '@typescript-eslint/types'

const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })

const usage = new Map<string, { count: number; files: Set<string> }>()

function recordClasses(value: string, file: string): void {
  for (const cls of value.split(/\s+/).filter(Boolean)) {
    const entry = usage.get(cls) ?? { count: 0, files: new Set() }
    entry.count += 1
    entry.files.add(file)
    usage.set(cls, entry)
  }
}

function walkLiteral(node: TSESTree.Node, file: string): void {
  if (node.type === 'Literal' && typeof node.value === 'string') {
    recordClasses(node.value, file)
  } else if (node.type === 'TemplateLiteral') {
    for (const q of node.quasis) recordClasses(q.value.cooked ?? '', file)
  }
}

for (const file of FILES) {
  const src = await readFile(file, 'utf8')
  const ast = parse(src, { jsx: true, range: false, loc: false })
  const walk = (node: TSESTree.Node): void => {
    if (node.type === 'JSXAttribute' && node.name.name === 'className' && node.value) {
      if (node.value.type === 'Literal') walkLiteral(node.value, file)
      if (node.value.type === 'JSXExpressionContainer') walkLiteral(node.value.expression, file)
    }
    for (const key of Object.keys(node) as Array<keyof TSESTree.Node>) {
      const child = node[key] as unknown
      if (Array.isArray(child)) for (const c of child) if (c && typeof c === 'object' && 'type' in c) walk(c as TSESTree.Node)
      else if (child && typeof child === 'object' && 'type' in child) walk(child as TSESTree.Node)
    }
  }
  walk(ast as unknown as TSESTree.Node)
}

const rows = [...usage.entries()]
  .map(([cls, e]) => ({ cls, count: e.count, fileCount: e.files.size }))
  .sort((a, b) => b.count - a.count)

const csv = ['class,count,file_count', ...rows.map((r) => `${r.cls},${r.count},${r.fileCount}`)].join('\n')
await writeFile('./out/tailwind-usage.csv', csv, 'utf8')
console.log(`extracted ${rows.length} unique classes across ${FILES.length} files`)

2. The token transformer

The transformer reads tailwind.config.ts (or the resolved Tailwind theme) and writes a single CSS file with every used token. Color tokens get a light and a dark value; spacing, radius, font-size, and shadow get one value each. The naming is semantic. bg-zinc-900 does not become --bg-zinc-900; it becomes --color-bg on the dark theme.

// scripts/tailwind/transform-tokens.ts
import { writeFile } from 'node:fs/promises'
import resolveConfig from 'tailwindcss/resolveConfig'
import twConfig from '../../tailwind.config'

const fullConfig = resolveConfig(twConfig)
const theme = fullConfig.theme

interface TokenMap {
  light: Record<string, string>
  dark: Record<string, string>
}

const tokens: TokenMap = { light: {}, dark: {} }

// Colors: semantic mapping, signed off by the designer
tokens.light['--color-bg'] = theme.colors.white as string
tokens.dark['--color-bg'] = (theme.colors.zinc as Record<string, string>)['900']
tokens.light['--color-text'] = (theme.colors.zinc as Record<string, string>)['900']
tokens.dark['--color-text'] = (theme.colors.zinc as Record<string, string>)['100']
tokens.light['--color-border'] = (theme.colors.zinc as Record<string, string>)['200']
tokens.dark['--color-border'] = (theme.colors.zinc as Record<string, string>)['800']

// Spacing: 4-pixel scale, six steps
for (const step of [1, 2, 3, 4, 6, 8]) {
  tokens.light[`--space-${step}`] = `${step * 4}px`
  tokens.dark[`--space-${step}`] = `${step * 4}px`
}

const lines: string[] = []
lines.push(':root {')
for (const [k, v] of Object.entries(tokens.light)) lines.push(`  ${k}: ${v};`)
lines.push('}', '')
lines.push('[data-theme="dark"] {')
for (const [k, v] of Object.entries(tokens.dark)) lines.push(`  ${k}: ${v};`)
lines.push('}', '')

await writeFile('./src/styles/tokens.css', lines.join('\n'), 'utf8')
console.log(`wrote ${Object.keys(tokens.light).length} tokens`)

3. The JSX codemod

The codemod walks the same files the extractor walked, finds the recurring utility strings, and replaces them with the BEM class name. The replacement table is a JSON file the developer reviews before the run. The codemod produces a diff; nothing lands without a human eye.

// scripts/tailwind/codemod.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'

interface Replacement {
  pattern: string
  replacement: string
  reason: string
}

const REPLACEMENTS: Replacement[] = JSON.parse(
  await readFile('./data/replacements.json', 'utf8'),
)

const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })

let totalReplacements = 0

for (const file of FILES) {
  let src = await readFile(file, 'utf8')
  let replacementsHere = 0
  for (const r of REPLACEMENTS) {
    const before = src
    src = src.replaceAll(r.pattern, r.replacement)
    if (src !== before) replacementsHere += 1
  }
  if (replacementsHere > 0) {
    await writeFile(file, src, 'utf8')
    totalReplacements += replacementsHere
    console.log(`${file}: ${replacementsHere} replacements`)
  }
}

console.log(`total: ${totalReplacements} replacements across ${FILES.length} files`)

4. The dark-mode bridge

The bridge does two things at once: it strips the dark: prefix from every Tailwind class and rewires the JSX to read tokens. The tokens already have the dark values gated by [data-theme], so once the prefix is gone, dark mode keeps working. The bridge is a single pass; it runs once per file and the file is done.

// scripts/tailwind/strip-dark-prefix.ts
import { readFile, writeFile } from 'node:fs/promises'
import { glob } from 'glob'

const DARK_PREFIX = /dark:(\S+)/g

const FILES = await glob('src/**/*.{tsx,jsx}', { absolute: true })

for (const file of FILES) {
  const src = await readFile(file, 'utf8')
  // Remove every `dark:className` token; tokens carry the dark value now.
  const rewritten = src.replaceAll(DARK_PREFIX, '')
    .replace(/\s+"/g, '"')   // collapse whitespace introduced by the strip
    .replace(/"\s+/g, '"')
  if (rewritten !== src) {
    await writeFile(file, rewritten, 'utf8')
  }
}

5. The lint rule that holds the line

Once the migration ships, the most likely regression is a developer who reaches for <div className="flex items-center px-4"> out of habit. The Stylelint rule below scans .tsx files and fails the build when a known Tailwind utility class appears in JSX. The rule reads the same list the extractor produced; it stays in sync.

// stylelint/rules/no-tailwind-utilities.js
const KNOWN_TAILWIND = require('./tailwind-utility-list.json')

module.exports = {
  meta: { type: 'problem' },
  create(context) {
    return {
      JSXAttribute(node) {
        if (node.name.name !== 'className') return
        const value = node.value
        if (!value || value.type !== 'Literal') return
        const classes = String(value.value).split(/\s+/).filter(Boolean)
        for (const cls of classes) {
          if (KNOWN_TAILWIND.includes(cls)) {
            context.report({
              node,
              message: `Tailwind utility "${cls}" is no longer allowed. Use a named component or BEM class.`,
            })
          }
        }
      },
    }
  },
}

6. What this composes

The extractor produces the audit. The transformer writes the tokens. The codemod replaces the recurring strings with names. The bridge retires dark variants. The lint rule keeps the work from reverting.

Tailwind is removed; the design system is in its place. The CSS bundle is a third of what it was. Dark mode is one toggle, not a prefix on every line. The JSX is readable: every component says what it is. The designer changes a value in one place, and the next PR ships the change everywhere. The team that took two days to refresh a theme now ships the refresh in a single afternoon, and the next hire opens a component file without needing a Tailwind reference open.

Related stacks

Frequently asked questions

How much of the visual output changes during the migration?

Zero pixels, on purpose. The visual regression suite holds the line. The migration is a refactor of how the styles are expressed, not what they look like. A user clicking through the app the day after the cutover sees the same product. The change is in the codebase, the bundle, and the workflow, not in the UI.

Do we need a full design system already in place, or do you build one along the way?

We build the minimum design system the migration needs: tokens for every used value, components for every recurring pattern, a dark-mode bridge, and a runbook. The output is a real design system the team can grow afterwards. We do not build a 200-component library that the team did not ask for; we build the system that the existing UI demands and stop there.

What if our team likes Tailwind and does not want to give it up?

Then we do not run this migration. Tailwind is a real choice for some teams. We run this work for teams who have already hit one of three walls: dark mode that requires touching every line, theming that takes a sprint per change, or a JSX surface so dense with class strings that new hires take weeks to read it. If none of those problems is biting yet, the migration is premature.

How long does it take?

A typical SaaS frontend (50 to 100 components, 80 to 200 routes) takes six to eight weeks from kickoff. The audit takes one week. Token extraction and the BEM layer take two weeks. The codemod runs in batches across two to three weeks with visual regression between each batch. The final cleanup and lint rules take one week. Smaller projects ship faster; nothing useful ships in under three weeks.

What does the bundle size actually drop to?

Most apps land between 18 and 32 KB of CSS, gzipped. Pre-migration Tailwind plus typical plugins runs 70 to 200 KB depending on configuration. The biggest wins come from killing the `dark:` doubling and the long tail of unused utility variants that Tailwind generates by default. The number that matters is not the CSS size; it is the time-to-first-paint on mobile, which usually improves by 80 to 200 milliseconds.

Can you migrate only part of the app?

Yes, but it costs more per file than a full migration. A partial migration leaves Tailwind in the dependency tree, the dark-mode bridge has to coexist with `dark:` variants, and the lint rules have to be route-scoped. We do partial migrations when the buyer has a deadline that forces it; we always document the residual scope so the second half is a known number, not a surprise.

What about Tailwind plugins and third-party UI libraries?

Each one gets a row in the audit. Plugins that produce design tokens (e.g. typography, forms) get their values extracted into the token file; the plugin is then removed. Third-party React libraries that ship Tailwind classes (e.g. shadcn/ui copies) get their styles rewritten into the BEM layer or the design system. Headless libraries (Radix, Headless UI) stay; they do not depend on Tailwind.

Do you replace our designer?

No. The migration makes the designer's work load-bearing instead of advisory. The token file is the design contract; once it is signed, every value the designer ships propagates through the codebase via the codemod. Designers usually find the migration freeing because the next refresh takes one PR, not a sprint.

Scope your Tailwind removal

A scoping call, an audit of the JSX in week one, a fixed scope and a number we hold. Six to eight weeks from kickoff to a codebase the team and the next designer share.