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.