Dalla soup di utility a un design system che il prossimo sviluppatore riesce a leggere
Tiriamo fuori ogni utility usata trasformandola in un token, raccogliamo ogni stringa di classi ricorrente in un componente con un nome, e riscriviamo la JSX in modo che ogni riga dica cosa è quell'elemento, non come va dipinto. Il dark mode parte dai token su `[data-theme]`. La build sputa un file CSS sotto i 30 KB. Il codemod prova che nulla di visivo è cambiato.
Il problema
Tailwind compra velocità all'inizio e fa pagare interessi a vita
Lo stesso progetto che ha spedito la prima schermata in un weekend, due anni dopo, è un muro di `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`. Sei di quelle stringhe differiscono per un solo token. Nessuna ha un nome. Il designer cambia uno spacing e il team fa grep-replace su 240 file. Un refresh del tema diventa uno sprint, non un venerdì pomeriggio. Chiudiamo il buco leggendo il codice, mappando ciò che viene usato davvero, promuovendo i pattern ricorrenti a componenti con un nome, e sostituendo le stringhe di utility con classi che significano qualcosa. L'output è un layer CSS sotto i 30 KB, un file di token che il designer aggiorna in un posto solo, e una JSX che si spiega da sola senza avere la cheatsheet di Tailwind aperta in un'altra finestra.
Come lo affrontiamo
Sei passi da un codebase Tailwind a un design system tipizzato
L'ordine non si discute. Audit prima di estrarre, estrarre prima di dare nomi, dare nomi prima di riscrivere, riscrivere prima di cancellare. Togliere Tailwind prima che il design system sia in piedi produce un codebase peggiore di quello da cui si era partiti.
- 01
Audit dell'impronta delle utility
Facciamo un passaggio statico che elenca ogni classe Tailwind usata nel repo, con la frequenza. L'output è un CSV: 1.847 classi totali, 312 uniche, le prime 30 coprono l'84% degli usi. L'audit divide la coda lunga (classi one-off, di solito segno di debito di design) dalla testa (i pattern che sono già componenti in tutto tranne che nel nome). Il CSV è il brief per i passi successivi; senza, nessuno si muove.
- 02
Estrarre i token
Ogni colore, valore di spacing, font size, radius e shadow già presente in `tailwind.config.ts` diventa una custom property CSS. Il naming segue la semantica che il design system chiede, non il nome della palette Tailwind. `bg-zinc-900` non diventa `--bg-zinc-900`; diventa `--color-bg` (light: white; dark: zinc-900). Il primo segnale che il lavoro è sui binari giusti è che i token di colore si leggono come significati, non come campioni.
- 03
Promuovere le stringhe ricorrenti a componenti con un nome
Ogni stringa di utility tra le prime 30, presente 10 o più volte nel codebase, diventa un componente o una classe BEM con un nome. `flex items-center justify-between gap-3 px-4 py-2 rounded-md ...` diventa `<ListRow>` oppure `.list-row`. La stringa ricorrente viene riscritta esattamente una volta; in tutti gli altri posti, la renderizza il componente. Ogni promozione è una PR con uno screenshot diff e il conteggio delle sostituzioni.
- 04
Cablare il dark mode attraverso i token
Tailwind esprime il dark mode ripetendo ogni riga con un prefisso `dark:`. Il nuovo sistema lo esprime una volta sola: ogni token ha un valore light e uno dark, governati da `[data-theme]`. La JSX smette del tutto di portarsi dietro informazioni sul tema. I componenti non sanno se stanno renderizzando light o dark. Il toggle sono due righe in un layout component.
- 05
Far girare il codemod
Uno script riscrive ogni file JSX: stringhe di utility sostituite dalla classe con nome, varianti dark rimosse (ci pensano i token), valori arbitrari segnalati per revisione umana. Il codemod esce in batch da 30 a 60 file, ognuno con la sua passata di visual regression. La descrizione della PR riporta il conteggio file, il delta classi e il delta di bundle size. Tra screenshot prima e dopo, nulla cambia visivamente.
- 06
Cancellare Tailwind
Quando ogni file JSX è migrato e la suite di visual regression è verde, `tailwind` e i suoi plugin escono da `package.json`. `tailwind.config.ts` viene rimosso. La pipeline PostCSS si libera di un plugin. Il bundle CSS finale dichiara la sua misura vera: di solito sotto i 30 KB per un'app reale, contro i 70-200 KB di Tailwind pre-purge. Il team spedisce lo stesso prodotto su un terzo del CSS.
Cosa consegniamo
Audit di utilizzo delle classi
Un CSV con ogni classe Tailwind usata nel repo, la frequenza, il numero di file e un tag tra `head`, `mid` o `tail`. Il manufatto che guida il resto della migrazione. Ri-eseguibile; il codemod lo legge come input.
Mappa di estrazione dei token
Una tabella markdown che mappa ogni token Tailwind usato sulla nuova variabile CSS semantica. Il designer firma il documento prima che parta la riscrittura; quando è firmato, la riscrittura è meccanica.
Log di promozione dei componenti
Una riga per ogni pattern promosso: la stringa di utility originale, il nome del nuovo componente o della BEM class, il conteggio file e uno screenshot diff. Il log è la traccia di audit per quando, sei mesi dopo, uno sviluppatore chiede perché esiste una certa classe.
File CSS dei token
Un unico file CSS con ogni variabile `--color-*`, `--space-*`, `--radius-*`, `--shadow-*`, `--font-*`, con valori light e dark governati da `[data-theme]`. Sostituisce `tailwind.config.ts` come fonte di verità di design. Versionato.
Layer BEM
Un layer CSS con le classi con nome che assorbono le stringhe di utility ricorrenti. Il file è scritto a mano, indentato, commentato. Niente magie PostCSS; niente generato. Lo sviluppatore successivo lo legge come un foglio di stile, non come un manufatto di build.
Script di codemod
Due script Node: uno riscrive le stringhe di utility JSX in classi con nome; l'altro toglie le varianti `dark:` e le sostituisce con token theme-aware. Restano nel repo per usi futuri; entrambi producono diff che un umano può rivedere.
Baseline di visual regression
Una suite Playwright che fa screenshot di ogni route chiave in light e dark prima dell'inizio della migrazione. La stessa suite gira dopo ogni batch; la migrazione passa solo a diff zero. La divergenza diventa un errore di build, non una sorpresa al giorno della release.
Documento del pattern dark mode
Un README breve che spiega come funziona il dark mode nel nuovo sistema, con tre esempi (un bottone, una card, un chart). Sostituisce il modello mentale implicito del `dark:` di Tailwind. Il documento che lo sviluppatore successivo legge il primo giorno.
Report di bundle size
Un diff di bundle CSS prima e dopo, suddiviso per file. Pre-migrazione Tailwind più i suoi plugin; post-migrazione il file di token più il layer BEM. Il numero che giustifica la migrazione al founder che l'ha finanziata.
Copertura Storybook
Una voce Storybook per ogni componente promosso, con i control per varianti e tema. I designer ci cliccano dentro contro la URL di staging. Sostituisce il paragrafo `flex items-center px-4` nella chat del team con un permalink.
Regole ESLint e Stylelint
Regole di lint che bloccano l'aggiunta di nuove utility Tailwind nella JSX dopo che la migrazione è uscita. Le regole leggono il CSS dei token come ground truth; valori in pixel arbitrari e hex fanno fallire la build. La migrazione resta migrata.
Runbook di migrazione
Un README nel repo che descrive come un cambio di codice futuro usa token e BEM invece delle utility. Dove aggiungere un nuovo token, come dare un nome a una nuova classe, quando chiedere al designer un nuovo valore invece di riusarne uno. Il documento che sopravvive al prossimo turnover di team.
Cinque file che portano un codebase Tailwind a un design system tipizzato
I cinque file qui sotto compongono la pipeline di migrazione. L'estrattore di classi che legge il repo e produce il CSV di utilizzo, il trasformatore di token che scrive le variabili CSS, il codemod che riscrive la JSX in classi con nome, il bridge dark-mode che manda in pensione ogni prefisso `dark:`, e la regola di lint che impedisce a Tailwind di rientrare dalla finestra.
Una rimozione di Tailwind è un problema di refactor prima che un problema di CSS. Il repo codifica già un design system; semplicemente esprime ogni componente come una stringa di utility invece che come un nome. Il nostro lavoro è leggere quello che c'è, dare un nome ai pattern ricorrenti, e riscrivere la JSX in modo che ogni riga dica cosa è l'elemento. I pixel restano dove sono. Il codice torna leggibile.
I cinque file qui sotto compongono la pipeline. L'estrattore di classi che produce l'audit, il trasformatore di token che scrive le variabili CSS, il codemod che riscrive la JSX, il bridge dark-mode che ritira il prefisso dark:, e la regola di lint che tiene la linea.
1. L'estrattore di classi
Leggiamo ogni file .tsx e .jsx nel repo, estraiamo ogni literal di className, e contiamo le utility class. L'output è un CSV che il resto della pipeline consuma. L'estrattore usa un piccolo passaggio AST (non una regex), perché le classi Tailwind compaiono in template literal, dentro chiamate a cn() e in espressioni condizionali, e una regex si perde la seconda e la terza forma.
// 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. Il trasformatore di token
Il trasformatore legge tailwind.config.ts (o il theme Tailwind risolto) e scrive un solo file CSS con ogni token usato. I token colore prendono un valore light e uno dark; spacing, radius, font-size e shadow ne prendono uno solo. Il naming è semantico. bg-zinc-900 non diventa --bg-zinc-900; diventa --color-bg sul tema dark.
// 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: {} }
// Colori: mapping semantico, controfirmato dal 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: scala da 4 px, sei step
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. Il codemod JSX
Il codemod cammina sugli stessi file che l'estrattore ha camminato, trova le stringhe di utility ricorrenti, e le sostituisce con il nome della BEM class. La tabella di sostituzione è un JSON che lo sviluppatore rivede prima del run. Il codemod produce un diff; nulla atterra senza un occhio umano.
// 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. Il bridge dark-mode
Il bridge fa due cose insieme: strippa il prefisso dark: da ogni classe Tailwind e ricabla la JSX a leggere dai token. I token hanno già i valori dark gestiti da [data-theme], quindi appena il prefisso sparisce, il dark mode continua a funzionare. Il bridge è un passaggio singolo; gira una volta per file e il file è a posto.
// 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')
// Toglie ogni token `dark:className`; ora il valore dark vive nei token.
const rewritten = src.replaceAll(DARK_PREFIX, '')
.replace(/\s+"/g, '"') // chiude gli spazi introdotti dallo strip
.replace(/"\s+/g, '"')
if (rewritten !== src) {
await writeFile(file, rewritten, 'utf8')
}
}
5. La regola di lint che tiene la linea
Una volta uscita la migrazione, la regressione più probabile è uno sviluppatore che per abitudine torna a scrivere <div className="flex items-center px-4">. La regola Stylelint qui sotto scansiona i file .tsx e fa fallire la build quando una utility Tailwind nota compare nella JSX. La regola legge la stessa lista che ha prodotto l'estrattore; resta sincronizzata.
// 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: `La utility Tailwind "${cls}" non è più consentita. Usa un componente con un nome o una BEM class.`,
})
}
}
},
}
},
}
6. Cosa compone questo
L'estrattore produce l'audit. Il trasformatore scrive i token. Il codemod sostituisce le stringhe ricorrenti con nomi. Il bridge manda in pensione le varianti dark. La regola di lint impedisce al lavoro di tornare indietro.
Tailwind sparisce; al suo posto c'è il design system. Il bundle CSS è un terzo di quello che era. Il dark mode è un toggle, non un prefisso su ogni riga. La JSX si legge: ogni componente dice cosa è. Il designer cambia un valore in un posto solo, e la PR successiva spedisce il cambio ovunque. Il team che impiegava due giorni per un refresh del tema ora lo fa in un pomeriggio, e chi entra il primo giorno apre un file di componente senza dover tenere aperta la reference di Tailwind.
Domande frequenti
Quanto cambia dell'output visivo durante la migrazione?
Zero pixel, di proposito. La suite di visual regression tiene la linea. La migrazione è un refactor di come gli stili sono espressi, non di come appaiono. Un utente che clicca dentro l'app il giorno dopo il cutover vede lo stesso prodotto. Il cambio è nel codebase, nel bundle e nel workflow, non nella UI.
Serve già un design system completo, o lo costruite voi strada facendo?
Costruiamo il design system minimo che serve alla migrazione: token per ogni valore usato, componenti per ogni pattern ricorrente, un bridge dark-mode e un runbook. L'output è un design system vero che il team può far crescere dopo. Non costruiamo una libreria da 200 componenti che il team non ha chiesto; costruiamo il sistema che la UI esistente richiede e ci fermiamo lì.
E se il team ama Tailwind e non vuole lasciarlo?
Allora questa migrazione non la facciamo. Tailwind è una scelta vera per alcuni team. Lavoriamo per chi è già sbattuto contro uno di tre muri: un dark mode che obbliga a toccare ogni riga, un retheming che costa uno sprint a cambio, oppure una superficie JSX così densa di stringhe di classi che chi entra nuovo ci mette settimane a leggerla. Se nessuno di questi problemi sta mordendo ancora, la migrazione è prematura.
Quanto ci vuole?
Un frontend SaaS tipico (50-100 componenti, 80-200 route) prende sei-otto settimane dal kickoff. L'audit prende una settimana. L'estrazione dei token e il layer BEM prendono due settimane. Il codemod gira in batch su due-tre settimane con visual regression tra un batch e l'altro. Cleanup finale e regole di lint prendono una settimana. Progetti più piccoli escono prima; sotto le tre settimane non esce nulla di utile.
Di quanto cala davvero il bundle size?
La maggior parte delle app atterra tra 18 e 32 KB di CSS, gzippato. Pre-migrazione, Tailwind più i plugin tipici sta su 70-200 KB a seconda della configurazione. La vittoria più grossa viene dal raddoppio del `dark:` che sparisce e dalla coda lunga di varianti di utility inutilizzate che Tailwind genera di default. Il numero che conta non è la dimensione del CSS; è il time-to-first-paint su mobile, che di solito migliora di 80-200 millisecondi.
Si può migrare solo una parte dell'app?
Sì, ma costa di più per file di una migrazione completa. Una migrazione parziale lascia Tailwind nelle dipendenze, il bridge dark-mode deve convivere con le varianti `dark:`, e le regole di lint vanno scopate per route. Le facciamo quando il cliente ha una deadline che lo impone; documentiamo sempre lo scope residuo, così la seconda metà è un numero noto, non una sorpresa.
E i plugin Tailwind e le librerie UI di terzi?
Ciascuno prende una riga nell'audit. I plugin che producono token di design (es. typography, forms) vengono estratti nel file dei token e poi rimossi. Le librerie React di terzi che spediscono classi Tailwind (es. copie di shadcn/ui) si riscrivono nel layer BEM o nel design system. Le librerie headless (Radix, Headless UI) restano; non dipendono da Tailwind.
Sostituite il nostro designer?
No. La migrazione rende il lavoro del designer load-bearing, non più advisory. Il file dei token è il contratto di design; una volta firmato, ogni valore che il designer cambia si propaga nel codice via codemod. I designer di solito si trovano liberati: il refresh successivo è una PR, non uno sprint.
Definisci lo scope della rimozione Tailwind
Una call di scoping, un audit della JSX nella prima settimana, uno scope fisso e un numero che teniamo. Sei-otto settimane dal kickoff a un codebase che team e prossimo designer condividono.