Caso de uso · Eliminación de Tailwind

De la sopa de utility a un design system que el siguiente desarrollador pueda leer

Extraemos cada utility usada como token, agrupamos cada string de clases recurrente en un componente con nombre, y reescribimos el JSX para que cada línea diga qué es el elemento, no cómo se pinta. El dark mode sale de los tokens en `[data-theme]`. La build entrega un archivo CSS por debajo de 30 KB. El codemod demuestra que nada visual cambió.

El problema

Tailwind te vende velocidad al principio y te cobra intereses de por vida

El mismo proyecto que sacó su primera pantalla un fin de semana, dos años después, es una pared de `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`. Seis de esos strings se diferencian por un solo token. Ninguno tiene nombre. El diseñador cambia un valor de spacing y el equipo hace grep-replace en 240 archivos. Refrescar el tema pasa a ser un sprint, no un viernes por la tarde. Cerramos el hueco leyendo el código, mapeando lo que se usa de verdad, promocionando los patrones recurrentes a componentes con nombre, y sustituyendo los strings de utility por clases que significan algo. La salida es una capa CSS por debajo de 30 KB, un archivo de tokens que el diseñador actualiza en un solo sitio, y un JSX que se explica solo sin tener la cheatsheet de Tailwind abierta en otra ventana.

Cómo lo abordamos

Seis pasos de un codebase Tailwind a un design system tipado

El orden no se discute. Audit antes de extraer, extraer antes de poner nombre, poner nombre antes de reescribir, reescribir antes de borrar. Quitar Tailwind antes de tener el design system en pie produce un codebase peor que el de partida.

  1. 01

    Auditar la huella de las utility

    Hacemos una pasada estática que lista cada clase Tailwind usada en el repo, con su frecuencia. La salida es un CSV: 1.847 clases en total, 312 únicas, las 30 primeras cubren el 84% del uso. El audit separa la cola larga (clases one-off, normalmente síntoma de deuda de diseño) de la cabeza (los patrones que ya son componentes en todo menos en el nombre). El CSV es el brief de los pasos siguientes; sin él no se mueve nadie.

  2. 02

    Extraer los tokens

    Cada color, cada valor de spacing, cada font size, cada radius y cada shadow que ya está en `tailwind.config.ts` pasa a custom property CSS. El naming sigue la semántica que pide el design system, no el nombre de la paleta Tailwind. `bg-zinc-900` no pasa a `--bg-zinc-900`; pasa a `--color-bg` (light: white; dark: zinc-900). La primera señal de que el trabajo va por buen camino es que los tokens de color se leen como significados, no como muestras.

  3. 03

    Promocionar los strings recurrentes a componentes con nombre

    Cada string de utility entre los 30 primeros que aparece 10 o más veces en el codebase pasa a componente o BEM class con nombre. `flex items-center justify-between gap-3 px-4 py-2 rounded-md ...` pasa a `<ListRow>` o `.list-row`. El string recurrente se reescribe una sola vez; en el resto de sitios, lo renderiza el componente. Cada promoción es una PR con screenshot diff y conteo de sustituciones.

  4. 04

    Cablear el dark mode a través de los tokens

    Tailwind expresa el dark mode repitiendo cada línea con un prefijo `dark:`. El nuevo sistema lo expresa una sola vez: cada token tiene un valor light y otro dark, gobernados por `[data-theme]`. El JSX deja de cargar información de tema del todo. Los componentes no saben si están renderizando light o dark. El toggle son dos líneas en un layout component.

  5. 05

    Lanzar el codemod

    Un script reescribe cada archivo JSX: strings de utility sustituidos por la clase con nombre, variantes dark eliminadas (las absorben los tokens), valores arbitrarios marcados para revisión humana. El codemod sale en lotes de 30 a 60 archivos, cada uno con su pase de visual regression. La descripción de la PR lleva el conteo de archivos, el delta de clases y el delta de bundle. Entre screenshots de antes y después, nada cambia visualmente.

  6. 06

    Borrar Tailwind

    Cuando cada archivo JSX está migrado y la suite de visual regression está en verde, `tailwind` y sus plugins salen de `package.json`. `tailwind.config.ts` se elimina. La pipeline PostCSS suelta un plugin. El bundle CSS final declara su tamaño real: normalmente por debajo de 30 KB en una app de verdad, frente a los 70-200 KB del Tailwind pre-purge. El equipo entrega el mismo producto sobre un tercio del CSS.

Qué entregamos

Audit de uso de clases

Un CSV con cada clase Tailwind usada en el repo, su frecuencia, conteo de archivos y un tag entre `head`, `mid` o `tail`. El artefacto que conduce el resto de la migración. Re-ejecutable; el codemod lo lee como input.

Mapa de extracción de tokens

Una tabla markdown que mapea cada token Tailwind usado a la nueva variable CSS semántica. El diseñador firma el documento antes de que arranque la reescritura; firmado el documento, la reescritura es mecánica.

Log de promoción de componentes

Una fila por patrón promocionado: el string de utility original, el nombre del nuevo componente o BEM class, el conteo de archivos y un screenshot diff. El log es el rastro de audit cuando, seis meses después, un desarrollador pregunta por qué existe cierta clase.

Archivo CSS de tokens

Un único archivo CSS con cada variable `--color-*`, `--space-*`, `--radius-*`, `--shadow-*`, `--font-*`, con valores light y dark gobernados por `[data-theme]`. Sustituye a `tailwind.config.ts` como fuente de verdad de diseño. Versionado.

Capa BEM

Una capa CSS con las clases con nombre que absorben los strings de utility recurrentes. El archivo está escrito a mano, indentado, comentado. Sin magia PostCSS; nada generado. El siguiente desarrollador lo lee como una hoja de estilos, no como un artefacto de build.

Scripts de codemod

Dos scripts Node: uno reescribe los strings de utility JSX a clases con nombre; el otro quita las variantes `dark:` y las sustituye por tokens theme-aware. Quedan en el repo para futuros usos; los dos producen diffs revisables por un humano.

Baselines de visual regression

Una suite Playwright que hace screenshot de cada ruta clave en light y dark antes de empezar la migración. La misma suite corre tras cada lote; la migración pasa solo cuando el diff es cero. La divergencia pasa a ser un error de build, no una sorpresa el día de release.

Documento del patrón de dark mode

Un README corto que explica cómo funciona el dark mode en el sistema nuevo, con tres ejemplos (un botón, una card, un chart). Sustituye al modelo mental implícito del `dark:` de Tailwind. El documento que lee el siguiente desarrollador el primer día.

Informe de bundle size

Un diff del bundle CSS antes y después, desglosado por archivo. Pre-migración, Tailwind más sus plugins; post-migración, el archivo de tokens más la capa BEM. El número que justifica la migración al founder que la financió.

Cobertura en Storybook

Una entrada Storybook por cada componente promocionado, con controles para variantes y tema. Los diseñadores hacen clic dentro contra la URL de staging. Sustituye el párrafo de `flex items-center px-4` en el chat del equipo por un permalink.

Reglas ESLint y Stylelint

Reglas de lint que bloquean la entrada de nuevas utility de Tailwind en el JSX después de que sale la migración. Las reglas leen el CSS de tokens como ground truth; píxeles arbitrarios y hex hacen fallar la build. La migración se queda migrada.

Runbook de migración

Un README en el repo que describe cómo un cambio de código futuro usa tokens y BEM en lugar de utility. Dónde añadir un token nuevo, cómo poner nombre a una clase nueva, cuándo pedirle al diseñador un valor nuevo en vez de reusar uno existente. El documento que sobrevive al siguiente cambio de equipo.

Cinco archivos que llevan un codebase Tailwind a un design system tipado

Los cinco archivos de abajo componen la pipeline de migración. El extractor de clases que lee el repo y emite el CSV de uso, el transformador de tokens que escribe las variables CSS, el codemod que reescribe el JSX en clases con nombre, el bridge dark-mode que jubila cada prefijo `dark:`, y la regla de lint que impide que Tailwind vuelva a colarse.

Una eliminación de Tailwind es un problema de refactor antes que un problema de CSS. El repo ya codifica un design system; lo que pasa es que expresa cada componente como un string de utility en vez de como un nombre. Nuestro trabajo es leer lo que hay, poner nombre a los patrones recurrentes, y reescribir el JSX para que cada línea diga qué es el elemento. Los píxeles se quedan donde están. El código vuelve a ser legible.

Los cinco archivos de abajo componen la pipeline. El extractor de clases que produce el audit, el transformador de tokens que escribe las variables CSS, el codemod que reescribe el JSX, el bridge dark-mode que retira el prefijo dark:, y la regla de lint que mantiene la línea.

1. El extractor de clases

Leemos cada archivo .tsx y .jsx del repo, sacamos cada literal de className, y contamos las utility class. La salida es un CSV que consume el resto de la pipeline. El extractor usa un pequeño pase AST (no una regex), porque las clases Tailwind aparecen en template literals, dentro de llamadas a cn() y en expresiones condicionales, y una regex se pierde la segunda y la tercera 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. El transformador de tokens

El transformador lee tailwind.config.ts (o el theme Tailwind resuelto) y escribe un solo archivo CSS con cada token usado. Los tokens de color cogen un valor light y otro dark; spacing, radius, font-size y shadow cogen uno solo. El naming es semántico. bg-zinc-900 no pasa a --bg-zinc-900; pasa a --color-bg en el 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: {} }

// Colores: mapeo semántico, firmado por el diseñador
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: escala de 4 px, seis pasos
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. El codemod JSX

El codemod recorre los mismos archivos que recorrió el extractor, encuentra los strings de utility recurrentes, y los sustituye por el nombre de la BEM class. La tabla de sustituciones es un JSON que el desarrollador revisa antes del run. El codemod produce un diff; nada aterriza sin un ojo humano.

// 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. El bridge de dark mode

El bridge hace dos cosas a la vez: arranca el prefijo dark: de cada clase Tailwind y reconecta el JSX a leer de los tokens. Los tokens ya tienen los valores dark gobernados por [data-theme], así que en cuanto desaparece el prefijo, el dark mode sigue funcionando. El bridge es un pase único; corre una vez por archivo y el archivo está listo.

// 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')
  // Quita cada token `dark:className`; ahora el valor dark vive en los tokens.
  const rewritten = src.replaceAll(DARK_PREFIX, '')
    .replace(/\s+"/g, '"')   // cierra los espacios que deja el strip
    .replace(/"\s+/g, '"')
  if (rewritten !== src) {
    await writeFile(file, rewritten, 'utf8')
  }
}

5. La regla de lint que mantiene la línea

Una vez que sale la migración, la regresión más probable es un desarrollador que por costumbre vuelve a escribir <div className="flex items-center px-4">. La regla de Stylelint de abajo escanea los archivos .tsx y hace fallar la build cuando aparece una utility Tailwind conocida en el JSX. La regla lee la misma lista que produjo el extractor; se mantiene sincronizada.

// 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 de Tailwind "${cls}" ya no está permitida. Usa un componente con nombre o una BEM class.`,
            })
          }
        }
      },
    }
  },
}

6. Lo que esto compone

El extractor produce el audit. El transformador escribe los tokens. El codemod sustituye los strings recurrentes por nombres. El bridge jubila las variantes dark. La regla de lint impide que el trabajo retroceda.

Tailwind sale; en su sitio queda el design system. El bundle CSS es un tercio del que era. El dark mode es un toggle, no un prefijo en cada línea. El JSX se lee: cada componente dice qué es. El diseñador cambia un valor en un solo sitio, y la siguiente PR entrega el cambio en todas partes. El equipo que tardaba dos días en refrescar un tema ahora lo hace en una tarde, y el que entra nuevo abre un archivo de componente sin tener que tener la referencia de Tailwind abierta al lado.

Stacks relacionados

Preguntas frecuentes

¿Cuánto cambia la salida visual durante la migración?

Cero píxeles, a propósito. La suite de visual regression mantiene la línea. La migración es un refactor de cómo se expresan los estilos, no de cómo se ven. Un usuario que hace clic dentro de la app al día siguiente del cutover ve el mismo producto. El cambio está en el codebase, en el bundle y en el workflow, no en la UI.

¿Necesitamos un design system completo ya en pie, o lo construís sobre la marcha?

Construimos el design system mínimo que pide la migración: tokens para cada valor usado, componentes para cada patrón recurrente, un bridge de dark mode y un runbook. La salida es un design system de verdad que el equipo puede hacer crecer después. No construimos una librería de 200 componentes que el equipo no pidió; construimos el sistema que la UI existente exige y ahí paramos.

¿Y si el equipo está enamorado de Tailwind y no quiere soltarlo?

Entonces esta migración no la hacemos. Tailwind es una elección válida para algunos equipos. Hacemos este trabajo para equipos que ya se han chocado con uno de tres muros: un dark mode que obliga a tocar cada línea, un retheming que cuesta un sprint cada cambio, o una superficie JSX tan densa de strings de clases que el que entra nuevo tarda semanas en leerla. Si ninguno de esos problemas muerde todavía, la migración es prematura.

¿Cuánto tarda?

Un frontend SaaS típico (50-100 componentes, 80-200 rutas) lleva de seis a ocho semanas desde el kickoff. El audit lleva una semana. La extracción de tokens y la capa BEM llevan dos semanas. El codemod corre en lotes a lo largo de dos o tres semanas con visual regression entre lote y lote. La limpieza final y las reglas de lint llevan una semana. Proyectos más pequeños salen antes; por debajo de tres semanas no sale nada útil.

¿A cuánto baja realmente el bundle?

La mayoría de las apps aterrizan entre 18 y 32 KB de CSS, gzipped. Pre-migración, Tailwind más los plugins habituales se va a 70-200 KB según la configuración. La mayor ganancia viene del fin del doblado por `dark:` y de la cola larga de variantes de utility sin uso que Tailwind genera por defecto. El número que importa no es el tamaño del CSS; es el time-to-first-paint en móvil, que suele mejorar 80-200 milisegundos.

¿Se puede migrar solo una parte de la app?

Sí, pero cuesta más por archivo que una migración completa. Una migración parcial deja Tailwind en el árbol de dependencias, el bridge de dark mode tiene que convivir con las variantes `dark:`, y las reglas de lint hay que limitarlas por ruta. Las hacemos cuando el cliente tiene una fecha que lo impone; documentamos siempre el alcance pendiente, así la segunda mitad es un número conocido, no una sorpresa.

¿Qué pasa con los plugins de Tailwind y las librerías UI de terceros?

Cada uno se lleva una fila en el audit. Los plugins que producen tokens de diseño (p. ej. typography, forms) se llevan al archivo de tokens y luego se quita el plugin. Las librerías React de terceros que entregan clases Tailwind (p. ej. copias de shadcn/ui) se reescriben en la capa BEM o en el design system. Las librerías headless (Radix, Headless UI) se quedan; no dependen de Tailwind.

¿Reemplazáis a nuestro diseñador?

No. La migración hace que el trabajo del diseñador sea load-bearing en vez de advisory. El archivo de tokens es el contrato de diseño; una vez firmado, cada valor que el diseñador cambia se propaga por el código vía codemod. Los diseñadores suelen sentirse liberados: el siguiente refresh es una PR, no un sprint.

Define el alcance de tu eliminación de Tailwind

Una llamada de scoping, un audit del JSX en la primera semana, un alcance fijo y un número que mantenemos. De seis a ocho semanas desde el kickoff hasta un codebase que comparten el equipo y el siguiente diseñador.