Tailwind CSS v4: Qué cambió realmente y si vale la pena migrar
Configuración CSS-first, integración con @layer, container queries integradas, rendimiento del nuevo engine, breaking changes y mi experiencia honesta de migración de v3 a v4.
He usado Tailwind CSS desde la v1.x, cuando la mitad de la comunidad pensaba que era una aberración y la otra mitad no podía parar de producir con él. Cada versión mayor ha sido un salto significativo, pero la v4 es diferente. No es solo un release de funcionalidades. Es una reescritura arquitectónica desde cero que cambia el contrato fundamental entre tú y el framework.
Después de migrar dos proyectos en producción de v3 a v4 y empezar tres proyectos nuevos en v4 desde cero, tengo una imagen clara de qué es genuinamente mejor, qué está tosco, y si deberías migrar hoy. Sin hype, sin indignación — solo lo que observé.
El panorama general: Qué es realmente la v4#
Tailwind CSS v4 es tres cosas a la vez:
- Un nuevo engine — reescrito de JavaScript a Rust (el engine Oxide), haciendo los builds dramáticamente más rápidos
- Un nuevo paradigma de configuración — la configuración CSS-first reemplaza a
tailwind.config.jscomo la opción por defecto - Una integración más estrecha con la plataforma CSS —
@layernativo, container queries,@starting-styley cascade layers son ciudadanos de primera clase
El titular que verás en todos lados es "10x más rápido". Es real, pero subestima el cambio verdadero. El modelo mental para configurar y extender Tailwind ha cambiado fundamentalmente. Ahora trabajas con CSS, no con un objeto de configuración JavaScript que genera CSS.
Así se ve un setup mínimo de Tailwind v4:
/* app.css — this is the entire setup */
@import "tailwindcss";Eso es todo. Sin archivo de configuración. Sin configuración de plugins PostCSS (para la mayoría de setups). Sin directivas @tailwind base; @tailwind components; @tailwind utilities;. Un import, y estás corriendo.
Compara eso con v3:
/* v3 — app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;// v3 — tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};// v3 — postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};Tres archivos reducidos a una línea. Eso no es solo menos boilerplate — es menos superficie para errores de configuración. En v4, la detección de contenido es automática. Escanea los archivos de tu proyecto sin necesidad de que especifiques patrones glob.
Configuración CSS-First con @theme#
Este es el cambio conceptual más grande. En v3, personalizabas Tailwind a través de un objeto de configuración JavaScript:
// v3 — tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
500: "#3b82f6",
900: "#1e3a5f",
},
},
fontFamily: {
display: ["Inter Variable", "sans-serif"],
},
spacing: {
18: "4.5rem",
112: "28rem",
},
borderRadius: {
"4xl": "2rem",
},
},
},
};En v4, todo esto vive en CSS usando la directiva @theme:
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
--font-display: "Inter Variable", sans-serif;
--spacing-18: 4.5rem;
--spacing-112: 28rem;
--radius-4xl: 2rem;
}Al principio, me resistí a esto. Me gustaba tener un solo objeto JavaScript donde podía ver todo mi sistema de diseño. Pero después de una semana con el enfoque CSS, cambié de opinión por tres razones:
1. Las CSS custom properties nativas se exponen automáticamente. Cada valor que defines en @theme se convierte en una CSS custom property en :root. Eso significa que tus valores de tema son accesibles en CSS plano, en CSS Modules, en tags <style>, donde sea que CSS corra:
/* you get this for free */
:root {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
}/* use them anywhere — no Tailwind needed */
.custom-element {
border: 2px solid var(--color-brand-500);
}2. Puedes usar funcionalidades de CSS dentro de @theme. Media queries, light-dark(), calc() — CSS real funciona aquí porque es CSS real:
@theme {
--color-surface: light-dark(#ffffff, #0a0a0a);
--color-text: light-dark(#0a0a0a, #fafafa);
--spacing-container: calc(100vw - 2rem);
}3. Co-ubicación con tu otro CSS. Tu tema, tus utilidades personalizadas y tus estilos base viven en el mismo lenguaje, en el mismo archivo si quieres. No hay cambio de contexto entre "el mundo CSS" y "el mundo de configuración JavaScript".
Sobrescribir vs. extender el tema por defecto#
En v3 tenías theme (reemplazar) vs theme.extend (fusionar). En v4, el modelo mental es diferente:
@import "tailwindcss";
/* This EXTENDS the default theme — adds brand colors alongside existing ones */
@theme {
--color-brand-500: #3b82f6;
}Si quieres reemplazar completamente un namespace (como eliminar todos los colores por defecto), usas @theme con el reset de wildcard --color-*:
@import "tailwindcss";
@theme {
/* Clear all default colors first */
--color-*: initial;
/* Now define only your colors */
--color-white: #ffffff;
--color-black: #000000;
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
}Este patrón de reset con wildcard es elegante. Eliges exactamente qué partes del tema por defecto mantener y cuáles reemplazar. ¿Quieres todo el spacing por defecto pero colores personalizados? Resetea --color-*: initial; y deja el spacing en paz.
Múltiples archivos de tema#
Para proyectos más grandes, puedes dividir tu tema en archivos:
/* styles/theme/colors.css */
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a5f;
--color-brand-950: #172554;
}
/* styles/theme/typography.css */
@theme {
--font-display: "Inter Variable", sans-serif;
--font-body: "Source Sans 3 Variable", sans-serif;
--font-mono: "JetBrains Mono Variable", monospace;
--text-display: 3.5rem;
--text-display--line-height: 1.1;
--text-display--letter-spacing: -0.02em;
}/* app.css */
@import "tailwindcss";
@import "./theme/colors.css";
@import "./theme/typography.css";Esto es mucho más limpio que el patrón de v3 de tener un tailwind.config.js gigante o intentar dividirlo con require().
El Engine Oxide: Realmente es 10x más rápido#
El engine de Tailwind v4 es una reescritura completa en Rust. Lo llaman Oxide. Yo era escéptico del claim de "10x más rápido" — los números de marketing rara vez sobreviven el contacto con proyectos reales. Así que hice benchmarks.
Mi proyecto de prueba: Una app Next.js con 847 componentes, 142 páginas, aproximadamente 23,000 usos de clases Tailwind.
| Metric | v3 (Node) | v4 (Oxide) | Improvement |
|---|---|---|---|
| Initial build | 4,280ms | 387ms | 11x |
| Incremental (edit 1 file) | 340ms | 18ms | 19x |
| Full rebuild (clean) | 5,100ms | 510ms | 10x |
| Dev server start | 3,200ms | 290ms | 11x |
El claim de "10x" es conservador para mi proyecto. Los builds incrementales son donde realmente brilla — 18ms significa que es esencialmente instantáneo. Guardas un archivo y el navegador tiene los nuevos estilos antes de que puedas cambiar de tab.
¿Por qué es tanto más rápido?#
Tres razones:
1. Rust en lugar de JavaScript. El parser de CSS central, la detección de clases y la generación de código son todo Rust nativo. Esto no es una situación de "reescribamos en Rust por diversión" — el parsing de CSS es genuinamente trabajo CPU-bound donde el código nativo tiene una ventaja masiva sobre V8.
2. Sin PostCSS en la ruta crítica. En v3, Tailwind era un plugin de PostCSS. Cada build significaba: parsear CSS en el AST de PostCSS, ejecutar el plugin de Tailwind, serializar de vuelta a string CSS, luego otros plugins de PostCSS corren. En v4, Tailwind tiene su propio parser CSS que va directamente desde la fuente hasta la salida. PostCSS aún se soporta por compatibilidad, pero la ruta principal lo omite por completo.
3. Procesamiento incremental más inteligente. El nuevo engine cachea agresivamente. Cuando editas un solo archivo, solo re-escanea ese archivo buscando nombres de clases y solo regenera las reglas CSS que cambiaron. El engine v3 era más inteligente sobre esto de lo que la gente le reconoce (el modo JIT ya era incremental), pero v4 lo lleva mucho más lejos con tracking de dependencias de grano fino.
¿La velocidad realmente importa?#
Sí, pero no por la razón que esperarías. Para la mayoría de proyectos, la velocidad de build de v3 era "aceptable". Esperabas unos cientos de milisegundos en dev. No dolía.
La velocidad de v4 importa porque hace que Tailwind sea invisible en tu toolchain. Cuando los builds son de menos de 20ms, dejas de pensar en Tailwind como un paso de build en absoluto. Se vuelve como el syntax highlighting — siempre ahí, nunca estorbando. Esa diferencia psicológica es significativa a lo largo de un día completo de desarrollo.
Integración nativa con @layer#
En v3, Tailwind usaba su propio sistema de layers con @layer base, @layer components y @layer utilities. Parecían CSS cascade layers pero no lo eran — eran directivas específicas de Tailwind que controlaban dónde aparecía el CSS generado en la salida.
En v4, Tailwind usa CSS cascade layers reales:
/* v4 output — simplified */
@layer theme, base, components, utilities;
@layer base {
/* reset, preflight */
}
@layer components {
/* your component classes */
}
@layer utilities {
/* all generated utility classes */
}Este es un cambio significativo porque los CSS cascade layers tienen implicaciones reales de especificidad. Una regla en un layer de menor prioridad siempre pierde contra una regla en un layer de mayor prioridad, sin importar la especificidad del selector. Eso significa:
@layer components {
/* specificity: 0-1-0 */
.card { padding: 1rem; }
}
@layer utilities {
/* specificity: 0-1-0 — same specificity but wins because utilities layer is later */
.p-4 { padding: 1rem; }
}Las utilities siempre sobrescriben a los components. Los components siempre sobrescriben al base. Así es como Tailwind funcionaba conceptualmente en v3, pero ahora está reforzado por el mecanismo de cascade layers del navegador, no por manipulación del orden en el código fuente.
Agregando utilidades personalizadas#
En v3, definías utilidades personalizadas con la API de plugins o @layer utilities:
// v3 — plugin approach
const plugin = require("tailwindcss/plugin");
module.exports = {
plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
".text-balance": {
"text-wrap": "balance",
},
".text-pretty": {
"text-wrap": "pretty",
},
});
}),
],
};En v4, las utilidades personalizadas se definen con la directiva @utility:
@import "tailwindcss";
@utility text-balance {
text-wrap: balance;
}
@utility text-pretty {
text-wrap: pretty;
}La directiva @utility le dice a Tailwind "esto es una clase de utilidad — ponla en el layer de utilities y permite que se use con variantes". Esa última parte es clave. Una utilidad definida con @utility automáticamente funciona con hover:, focus:, md: y todas las demás variantes:
<p class="text-pretty md:text-balance">...</p>Variantes personalizadas#
También puedes definir variantes personalizadas con @variant:
@import "tailwindcss";
@variant hocus (&:hover, &:focus);
@variant theme-dark (.dark &);<button class="hocus:bg-brand-500 theme-dark:text-white">
Click me
</button>Esto reemplaza la API addVariant de plugins de v3 para la mayoría de casos de uso. Es menos poderoso (no puedes generar variantes programáticamente), pero cubre el 90% de lo que la gente realmente hace.
Container Queries: integradas, sin plugin#
Las container queries eran una de las funcionalidades más solicitadas en v3. Podías conseguirlas con el plugin @tailwindcss/container-queries, pero era un add-on. En v4, están integradas en el framework.
Uso básico#
Marca un contenedor con @container y consulta su tamaño con el prefijo @:
<!-- mark parent as a container -->
<div class="@container">
<!-- responsive to parent's width, not viewport -->
<div class="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
<div class="p-4">Card 1</div>
<div class="p-4">Card 2</div>
<div class="p-4">Card 3</div>
</div>
</div>Las variantes @md, @lg, etc. funcionan como breakpoints responsivos pero son relativas al ancestro @container más cercano en lugar del viewport. Los valores de breakpoint corresponden a los breakpoints por defecto de Tailwind:
| Variant | Min-width |
|---|---|
@sm | 24rem (384px) |
@md | 28rem (448px) |
@lg | 32rem (512px) |
@xl | 36rem (576px) |
@2xl | 42rem (672px) |
Contenedores con nombre#
Puedes nombrar contenedores para consultar ancestros específicos:
<div class="@container/sidebar">
<div class="@container/card">
<!-- queries the card container -->
<div class="@md/card:text-lg">...</div>
<!-- queries the sidebar container -->
<div class="@lg/sidebar:hidden">...</div>
</div>
</div>Por qué esto importa#
Las container queries cambian cómo piensas sobre el diseño responsivo. En lugar de "a este ancho de viewport, mostrar tres columnas", dices "cuando el contenedor de este componente sea lo suficientemente ancho, mostrar tres columnas". Los componentes se vuelven verdaderamente autónomos. Puedes mover un componente de card desde un layout de ancho completo a un sidebar y se adapta automáticamente. Sin gimnasia de media queries.
He estado refactorizando mis librerías de componentes para usar container queries por defecto en lugar de breakpoints de viewport. El resultado son componentes que funcionan en cualquier lugar donde los coloques, sin que el padre necesite saber nada sobre el comportamiento responsivo del componente.
<!-- This component adapts to ANY container it's placed in -->
<article class="@container">
<div class="grid grid-cols-1 @md:grid-cols-[200px_1fr] gap-4">
<img
class="w-full @md:w-auto rounded-lg aspect-video @md:aspect-square object-cover"
src="/post-image.jpg"
alt=""
/>
<div>
<h2 class="text-lg @lg:text-xl font-semibold">Post Title</h2>
<p class="mt-2 text-sm @md:text-base text-gray-600">
Post excerpt goes here...
</p>
<div class="mt-4 hidden @md:flex gap-2">
<span class="text-xs bg-gray-100 px-2 py-1 rounded">Tag</span>
</div>
</div>
</div>
</article>Nuevas variantes que realmente importan#
v4 agrega varias variantes nuevas a las que he recurrido constantemente. Llenan vacíos reales.
La variante starting:#
Esto mapea a CSS @starting-style, que te permite definir el estado inicial de un elemento cuando aparece por primera vez. Es la pieza que faltaba para animar la entrada de elementos sin JavaScript:
<dialog class="opacity-0 starting:opacity-0 open:opacity-100 transition-opacity duration-300">
<p>This dialog fades in when opened</p>
</dialog>La variante starting: genera CSS dentro de un bloque @starting-style:
/* what Tailwind generates */
@starting-style {
dialog[open] {
opacity: 0;
}
}
dialog[open] {
opacity: 1;
transition: opacity 300ms;
}Esto es enorme para diálogos, popovers, menús dropdown — cualquier cosa que necesite una animación de entrada. Antes de esto, necesitabas JavaScript para agregar una clase en el siguiente frame, o usabas @keyframes. Ahora es una clase de utilidad.
La variante not-*#
Negación. Algo que hemos querido desde siempre:
<!-- every child except the last gets a border -->
<div class="divide-y">
<div class="not-last:pb-4">Item 1</div>
<div class="not-last:pb-4">Item 2</div>
<div class="not-last:pb-4">Item 3</div>
</div>
<!-- style everything that's not disabled -->
<input class="not-disabled:hover:border-brand-500" />
<!-- negate data attributes -->
<div class="not-data-active:opacity-50">...</div>Las variantes nth-*#
Acceso directo a nth-child y nth-of-type:
<ul>
<li class="nth-1:font-bold">First item — bold</li>
<li class="nth-even:bg-gray-50">Even rows — gray bg</li>
<li class="nth-odd:bg-white">Odd rows — white bg</li>
<li class="nth-[3n+1]:text-brand-500">Every third+1 — brand color</li>
</ul>La sintaxis con corchetes (nth-[3n+1]) soporta cualquier expresión nth-child válida. Esto reemplaza mucho CSS personalizado que solía escribir para striping de tablas y patrones de grids.
La variante in-* (estado del padre)#
Es lo inverso de group-*. En lugar de "cuando mi padre (group) está en hover, estilízame", es "cuando estoy dentro de un padre que coincide con este estado, estilízame":
<div class="in-data-active:bg-brand-50">
This gets a background when any ancestor has data-active
</div>La variante **: deep universal#
Estiliza todos los descendientes, no solo los hijos directos. Es poder controlado — úsalo con moderación, pero es invaluable para contenido prose y salida de CMS:
<!-- all paragraphs inside this div, at any depth -->
<div class="**:data-highlight:bg-yellow-100">
<section>
<p data-highlight>This gets highlighted</p>
<div>
<p data-highlight>So does this, nested deeper</p>
</div>
</section>
</div>Breaking changes: Qué realmente se rompió#
Voy a ser directo. Si tienes un proyecto grande en v3, la migración no es trivial. Esto es lo que se rompió en mis proyectos:
1. Formato de configuración#
Tu tailwind.config.js no funciona directamente. Necesitas:
- Convertirlo a CSS con
@theme(recomendado para la nueva arquitectura) - Usar la capa de compatibilidad con la directiva
@config(ruta de migración rápida)
/* quick migration — keep your old config */
@import "tailwindcss";
@config "../../tailwind.config.js";Este puente @config funciona, pero es explícitamente una herramienta de migración. La recomendación es migrar a @theme con el tiempo.
2. Utilidades deprecadas eliminadas#
Algunas utilidades que fueron deprecadas en v3 ya no existen:
/* REMOVED in v4 */
bg-opacity-* → use bg-black/50 (slash opacity syntax)
text-opacity-* → use text-black/50
border-opacity-* → use border-black/50
flex-shrink-* → use shrink-*
flex-grow-* → use grow-*
overflow-ellipsis → use text-ellipsis
decoration-slice → use box-decoration-slice
decoration-clone → use box-decoration-clone
Si ya estabas usando la sintaxis moderna en v3 (slash opacity, shrink-*), estás bien. Si no, son cambios directos de buscar y reemplazar.
3. Cambios en la paleta de colores por defecto#
La paleta de colores por defecto cambió ligeramente. Si dependes de valores exactos de color de v3 (no por nombre sino por el valor hex real), podrías notar diferencias visuales. Los colores con nombre (blue-500, gray-200) siguen existiendo pero algunos valores hex cambiaron.
4. Detección de contenido#
v3 requería configuración explícita de content:
// v3
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
};v4 usa detección automática de contenido. Escanea la raíz de tu proyecto y encuentra archivos de plantilla automáticamente. Esto mayormente "funciona", pero si tienes una estructura de proyecto inusual (monorepo con paquetes fuera de la raíz del proyecto, archivos de plantilla en ubicaciones inesperadas), podrías necesitar configurar las rutas de fuentes explícitamente:
@import "tailwindcss";
@source "../shared-components/**/*.tsx";
@source "../design-system/src/**/*.tsx";5. Cambios en la API de plugins#
Si escribiste plugins personalizados, la API cambió. Las funciones addUtilities, addComponents, addBase y addVariant siguen funcionando a través de la capa de compatibilidad, pero el enfoque idiomático en v4 es CSS nativo:
// v3 plugin
plugin(function ({ addUtilities, theme }) {
addUtilities({
".scrollbar-hide": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
});
});/* v4 — just CSS */
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}La mayoría de plugins first-party (@tailwindcss/typography, @tailwindcss/forms, @tailwindcss/aspect-ratio) tienen versiones compatibles con v4. Los plugins de terceros son hit or miss — revisa su repositorio antes de migrar.
6. JIT es el único modo#
En v3, podías optar por no usar el modo JIT (aunque casi nadie lo hacía). En v4, no hay modo que no sea JIT. Todo se genera bajo demanda, siempre. Si tenías alguna razón para usar el viejo engine AOT (ahead-of-time), esa opción ya no existe.
7. Cambios en la sintaxis de algunas variantes#
Algunas variantes cambiaron de nombre o de comportamiento:
<!-- v3 -->
<div class="[&>*]:p-4">...</div>
<!-- v4 — the >* part now uses the inset variant syntax -->
<div class="*:p-4">...</div>La sintaxis de variantes arbitrarias [&...] sigue funcionando, pero v4 provee alternativas con nombre para patrones comunes.
Guía de migración: El proceso real#
Así es como realmente migré, no la ruta feliz de la documentación sino cómo fue realmente el proceso.
Paso 1: Ejecutar el codemod oficial#
Tailwind proporciona un codemod que maneja la mayoría de cambios mecánicos:
npx @tailwindcss/upgradeEsto hace mucho automáticamente:
- Convierte directivas
@tailwinda@import "tailwindcss" - Renombra clases de utilidad deprecadas
- Actualiza la sintaxis de variantes
- Convierte utilidades de opacidad a sintaxis slash (
bg-opacity-50abg-black/50) - Crea un bloque
@themebásico desde tu configuración
Lo que el codemod maneja bien#
- Renombres de clases de utilidad (casi perfecto)
- Cambios de sintaxis de directivas
- Valores simples de tema (colores, spacing, fonts)
- Migración de sintaxis de opacidad
Lo que el codemod NO maneja#
- Conversiones de plugins complejos
- Valores de configuración dinámicos (llamadas a
theme()en JavaScript) - Configuración de tema condicional (ej: valores de tema basados en el entorno)
- Migraciones de la API de plugins personalizados
- Casos edge de valores arbitrarios donde el nuevo parser interpreta diferente
- Nombres de clases construidos dinámicamente en JavaScript (template literals, concatenación de strings)
Paso 2: Arreglar la configuración de PostCSS#
Para la mayoría de setups, actualizarás tu configuración de PostCSS:
// postcss.config.js — v4
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};Nota: el nombre del plugin cambió de tailwindcss a @tailwindcss/postcss. Si estás usando Vite, puedes omitir PostCSS por completo y usar el plugin de Vite:
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});Paso 3: Convertir la configuración del tema#
Esta es la parte manual. Toma los valores del tema de tu tailwind.config.js y conviértelos a @theme:
// v3 config — before
module.exports = {
theme: {
extend: {
colors: {
brand: {
light: "#60a5fa",
DEFAULT: "#3b82f6",
dark: "#1d4ed8",
},
},
fontSize: {
"2xs": ["0.65rem", { lineHeight: "1rem" }],
},
animation: {
"fade-in": "fade-in 0.5s ease-out",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
},
},
},
};/* v4 CSS — after */
@import "tailwindcss";
@theme {
--color-brand-light: #60a5fa;
--color-brand: #3b82f6;
--color-brand-dark: #1d4ed8;
--text-2xs: 0.65rem;
--text-2xs--line-height: 1rem;
--animate-fade-in: fade-in 0.5s ease-out;
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}Fíjate que los keyframes salen de @theme y se vuelven @keyframes CSS regulares. El nombre de la animación en @theme solo los referencia. Esto es más limpio — los keyframes son CSS, deberían escribirse como CSS.
Paso 4: Testing de regresión visual#
Esto es innegociable. Después de la migración, abrí cada página de mi app y la revisé visualmente. También corrí mis tests de screenshots de Playwright (si los tienes). El codemod es bueno pero no perfecto. Cosas que detecté en la revisión visual:
- Algunos lugares donde la migración de sintaxis de opacidad produjo resultados ligeramente diferentes
- Output de plugins personalizados que no se trasladó
- Cambios en el stacking de z-index debido al orden de layers
- Algunos overrides con
!importantque se comportaron diferente con cascade layers
Paso 5: Actualizar dependencias de terceros#
Revisa cada paquete relacionado con Tailwind:
{
"@tailwindcss/typography": "^1.0.0",
"@tailwindcss/forms": "^1.0.0",
"@tailwindcss/container-queries": "REMOVE — built-in now",
"tailwindcss-animate": "check for v4 support",
"prettier-plugin-tailwindcss": "update to latest"
}El plugin @tailwindcss/container-queries ya no se necesita — las container queries están integradas. Otros plugins necesitan sus versiones compatibles con v4.
Trabajando con Next.js#
Como uso Next.js para la mayoría de proyectos, aquí está el setup específico.
Enfoque PostCSS (recomendado para Next.js)#
Next.js usa PostCSS internamente, así que el plugin de PostCSS es el ajuste natural:
npm install tailwindcss @tailwindcss/postcss// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};/* app/globals.css */
@import "tailwindcss";
@theme {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
}Ese es el setup completo. Sin tailwind.config.js, sin autoprefixer (v4 maneja los vendor prefixes internamente).
Orden de imports CSS#
Algo que me hizo tropezar: el orden de los imports CSS importa más en v4 por los cascade layers. Tu @import "tailwindcss" debería venir antes de tus estilos personalizados:
/* correct order */
@import "tailwindcss";
@import "./theme.css";
@import "./custom-utilities.css";
/* your inline @theme, @utility, etc. */Si importas CSS personalizado antes de Tailwind, tus estilos pueden terminar en un cascade layer de menor prioridad y ser sobrescritos inesperadamente.
Modo oscuro#
El modo oscuro funciona de la misma manera conceptualmente pero la configuración se movió a CSS:
@import "tailwindcss";
/* Use class-based dark mode (default is media-based) */
@variant dark (&:where(.dark, .dark *));Esto reemplaza la configuración de v3:
// v3
module.exports = {
darkMode: "class",
};El enfoque @variant es más flexible. Puedes definir el modo oscuro como quieras — basado en clase, basado en data-attribute, o basado en media-query:
/* data attribute approach */
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* media query — this is the default, so you don't need to declare it */
@variant dark (@media (prefers-color-scheme: dark));Compatibilidad con Turbopack#
Si estás usando Next.js con Turbopack (que ahora es el bundler de desarrollo por defecto), v4 funciona muy bien. El engine Rust se complementa bien con la arquitectura basada en Rust de Turbopack. Medí los tiempos de inicio en dev:
| Setup | v3 + Webpack | v3 + Turbopack | v4 + Turbopack |
|---|---|---|---|
| Cold start | 4.8s | 2.1s | 1.3s |
| HMR (CSS change) | 450ms | 180ms | 40ms |
Los 40ms de HMR para cambios CSS son apenas perceptibles. Se siente instantáneo.
Deep dive de rendimiento: Más allá de la velocidad de build#
Los beneficios del engine Oxide van más allá de la velocidad de build en crudo.
Uso de memoria#
v4 usa significativamente menos memoria. En mi proyecto de 847 componentes:
| Metric | v3 | v4 |
|---|---|---|
| Peak memory (build) | 380MB | 45MB |
| Steady-state (dev) | 210MB | 28MB |
Esto importa para pipelines de CI/CD donde la memoria está limitada, y para máquinas de desarrollo corriendo diez procesos simultáneamente.
Tamaño del CSS generado#
v4 genera CSS ligeramente más pequeño porque el nuevo engine es mejor en deduplicación y eliminación de código muerto:
v3 output: 34.2 KB (gzipped)
v4 output: 29.8 KB (gzipped)
Una reducción del 13% sin cambiar ningún código. No es transformador, pero es rendimiento gratis.
Tree shaking de valores del tema#
En v4, si defines un valor de tema pero nunca lo usas en tus templates, la CSS custom property correspondiente igual se emite (está en @theme, que mapea a variables :root). Sin embargo, las clases de utilidad para valores no usados no se generan. Esto es igual al comportamiento JIT de v3 pero vale la pena mencionarlo: tus CSS custom properties siempre están disponibles, incluso para valores sin uso de utilidades.
Si quieres evitar que ciertos valores del tema generen CSS custom properties, puedes usar @theme inline:
@theme inline {
/* These values generate utilities but NOT CSS custom properties */
--color-internal-debug: #ff00ff;
--spacing-magic-number: 3.7rem;
}Esto es útil para design tokens internos que no quieres exponer como variables CSS.
Avanzado: Composición de temas para multi-marca#
Un patrón que v4 hace significativamente más fácil es el theming multi-marca. Como los valores del tema son CSS custom properties, puedes intercambiarlos en runtime:
@import "tailwindcss";
@theme {
--color-brand: var(--brand-primary, #3b82f6);
--color-brand-light: var(--brand-light, #60a5fa);
--color-brand-dark: var(--brand-dark, #1d4ed8);
}
/* Brand overrides */
.theme-acme {
--brand-primary: #e11d48;
--brand-light: #fb7185;
--brand-dark: #9f1239;
}
.theme-globex {
--brand-primary: #059669;
--brand-light: #34d399;
--brand-dark: #047857;
}<body class="theme-acme">
<!-- all bg-brand, text-brand, etc. use Acme colors -->
<div class="bg-brand text-white">Acme Corp</div>
</body>En v3, esto requería un plugin personalizado o un setup complejo de variables CSS fuera de Tailwind. En v4, es natural — el tema son variables CSS, y las variables CSS hacen cascade. Este es el tipo de cosa que hace que el enfoque CSS-first se sienta correcto.
Lo que extraño de v3#
Permíteme ser equilibrado. Hay cosas que v3 hacía que genuinamente extraño en v4:
1. Configuración JavaScript para temas programáticos. Tenía un proyecto donde generábamos escalas de color a partir de un solo color de marca usando una función JavaScript en la configuración. En v4, no puedes hacer eso en @theme — necesitarías un paso de build que genere el archivo CSS, o computar los colores una vez y pegarlos. La capa de compatibilidad @config ayuda, pero no es la historia a largo plazo.
2. IntelliSense era mejor al principio. La extensión de VS Code para v3 tenía años de pulido. El IntelliSense de v4 funciona pero tenía algunos vacíos al principio — los valores personalizados de @theme a veces no autocompletaban, y las definiciones de @utility no siempre se detectaban. Esto ha mejorado sustancialmente con actualizaciones recientes, pero vale la pena mencionarlo.
3. Madurez del ecosistema. El ecosistema alrededor de v3 era enorme. Headless UI, Radix, shadcn/ui, Flowbite, DaisyUI — todo estaba testeado contra v3. El soporte para v4 se está desplegando pero no es universal. Tuve que enviar un PR a una librería de componentes para arreglar la compatibilidad con v4.
¿Deberías migrar?#
Aquí está mi framework de decisión después de vivir con v4 varias semanas:
Migra ahora si:#
- Estás empezando un proyecto nuevo (opción obvia — empieza con v4)
- Tu proyecto tiene plugins personalizados mínimos
- Quieres los beneficios de rendimiento para proyectos grandes
- Ya estás usando patrones modernos de Tailwind (slash opacity,
shrink-*, etc.) - Necesitas container queries y preferirías no agregar un plugin
Espera si:#
- Dependes fuertemente de plugins de terceros para Tailwind que no soportan v4 todavía
- Tienes configuración de tema programática compleja
- Tu proyecto es estable y no se desarrolla activamente (¿para qué tocarlo?)
- Estás en medio de un sprint de features (migra entre sprints, no durante)
No migres si:#
- Estás en v2 o anterior (actualiza a v3 primero, estabiliza, luego considera v4)
- Tu proyecto termina en los próximos meses (no vale la turbulencia)
Mi opinión honesta#
Para proyectos nuevos, v4 es la opción obvia. La configuración CSS-first es más limpia, el engine es dramáticamente más rápido, y las nuevas funcionalidades (container queries, @starting-style, nuevas variantes) son genuinamente útiles.
Para proyectos existentes, recomiendo un enfoque escalonado:
- Ahora: Empieza cualquier proyecto nuevo en v4
- Pronto: Experimenta convirtiendo un proyecto interno pequeño a v4
- Cuando estés listo: Migra proyectos en producción durante un sprint tranquilo, con testing de regresión visual
La migración no es dolorosa si te preparas para ella. El codemod maneja el 80% del trabajo. El 20% restante es manual pero directo. Calcula un día para un proyecto mediano, dos a tres días para uno grande.
Tailwind v4 es lo que Tailwind debió ser desde siempre. La configuración JavaScript siempre fue una concesión al tooling de su época. Configuración CSS-first, cascade layers nativos, un engine Rust — esto no son tendencias, es el framework poniéndose al día con la plataforma. La plataforma web mejoró, y Tailwind v4 se apoya en ella en lugar de luchar contra ella.
El movimiento de escribir tus design tokens en CSS, componerlos con funcionalidades CSS, y dejar que el propio cascade del navegador maneje la especificidad — esa es la dirección correcta. Tomó cuatro versiones mayores llegar aquí, pero el resultado es la versión más coherente de Tailwind hasta ahora.
Empieza tu próximo proyecto con ella. No vas a mirar atrás.