Módulo 16: Reutilización y componentes
Objetivos del módulo
- Decidir cuándo merece la pena extraer un patrón y cuándo es mejor dejar las utilidades repetidas
- Ordenar las formas de reutilizar: bucle, componente, clase CSS y utilidad propia
- Escribir clases en
@layer componentsy entender qué ganan y qué pierden respecto a las utilidades - Usar
@applycon mesura, sabiendo qué genera, cómo ordena las declaraciones y cuánto pesa de verdad - Convertir un patrón en utilidad con
@utilitypara que admita variantes comomd:yhover: - Explicar por qué
px-4 px-2no significa “gana la última” y por qué concatenar clases falla - Componer clases condicionales con
clsxy resolver conflictos contailwind-merge - Crear el asistente
cn()y configurartailwind-mergepara que entienda tus tokens - Describir variantes de forma declarativa con
class-variance-authority - Repartir una receta entre varios elementos con los slots de
tailwind-variants - Construir un sistema de botones completo: variantes, tamaños, estados, enlaces e iconos
- Construir tarjetas con toda la superficie clicable y badges que no dependan solo del color
- Comprobar la accesibilidad de un componente: foco, contraste, tamaño de pulsación, colores forzados y movimiento reducido
- Saber qué versión de cada librería corresponde a Tailwind 4 y a Tailwind 3.4
1. Preparación del laboratorio
Hasta ahora, los ejemplos del curso eran HTML puro. En este módulo vamos a necesitar lógica (“si la variante es peligro, pon estas clases”), así que usaremos JavaScript sin ningún framework: funciones que devuelven cadenas de clases y plantillas de texto que rellenan la página. Así verás lo que ocurre sin magia de por medio. En el módulo 18 llevarás estas mismas recetas a React y a Angular.
Sigues con el proyecto Vite del módulo 01. Instala las librerías del módulo:
npm install clsx tailwind-merge class-variance-authority tailwind-variants
Estas son las versiones con las que se ha comprobado todo el módulo:
| Paquete | Versión | Para qué sirve |
|---|---|---|
clsx | 2.1.1 | Unir clases condicionales |
tailwind-merge | 3.7.0 | Resolver conflictos entre clases de Tailwind |
class-variance-authority | 0.7.1 | Definir variantes (lo abreviaremos cva) |
tailwind-variants | 4.0.1 | Variantes con slots y resolución de conflictos propia |
Organiza el código así:
proyecto/
├── index.html
├── app.css
├── main.js
├── lib/
│ └── cn.js
└── componentes/
├── boton.js
├── insignia.js
└── tarjeta.js
Cada página de ejemplo tiene un contenedor y carga un módulo:
<div id="app"></div>
<script type="module" src="/main.js"></script>
⚠️ Importante: En estos ejemplos se usa
innerHTMLpara mantener el foco en las clases, y solo con datos escritos por nosotros. Nunca metas así texto que venga de un usuario: es la puerta de entrada de los ataques XSS. Los frameworks del módulo 18 escapan el texto por ti.
2. Cuándo extraer
Cuando llevas tres tarjetas iguales, el instinto dice “esto hay que convertirlo en una clase”. Ese instinto viene del CSS semántico y en Tailwind suele llevarte por mal camino. Antes de escribir nada, conviene preguntarse qué problema tienes exactamente.
La repetición no siempre es un problema
Copiar y pegar es peligroso cuando un cambio obliga a tocar muchos sitios y es fácil olvidarse de alguno. Si no pasa eso, repetir no hace daño. Fíjate en estos casos:
flex items-center gap-2aparece doscientas veces en un proyecto. No es duplicación: es una frase corta y corriente del idioma de Tailwind. Una clase.filano ahorraría nada y obligaría a aprender un nombre más.- Dos tarjetas se parecen hoy, pero una es de producto y otra de cliente. Si las unes y mañana evolucionan por separado, acabarás con un componente lleno de condiciones. Una duplicación mal unida cuesta más de deshacer que la duplicación original.
- Hay tres botones idénticos, repartidos por tres páginas, con hover, foco y estado deshabilitado. Aquí sí: cuando cambie el color de foco, alguien se dejará uno.
📘 Concepto: Extrae cuando varias copias significan lo mismo y deben cambiar a la vez. Que dos fragmentos se parezcan es una coincidencia; que deban evolucionar juntos es lo que justifica extraerlos.
Una regla práctica muy extendida es la regla de tres: la primera vez lo escribes, la segunda lo copias y la tercera te planteas extraerlo. Para entonces ya tienes tres ejemplos reales y sabes qué parte es común y qué parte cambia. Si extraes con un solo ejemplo, estás adivinando.
Cuatro niveles
Hay cuatro formas de reutilizar, y conviene probarlas en orden:
Figura 1. Cada nivel resuelve menos cosas que el anterior o introduce más abstracción. Sube solo cuando el nivel inferior no te baste.
Muchas repeticiones ni siquiera necesitan un componente. Si las tres tarjetas salen de los mismos datos, basta con un bucle:
const productos = [
{ nombre: 'Portátil UltraBook 14', descripcion: 'Portátil ligero de 14 pulgadas, 16 GB RAM', precio: 899 },
{ nombre: 'Portátil Gaming 16', descripcion: 'Portátil de 16 pulgadas con gráfica dedicada', precio: 1499 },
{ nombre: 'Pack teclado y ratón', descripcion: 'Conjunto inalámbrico de teclado y ratón', precio: 34.5 },
];
const euros = (n) => n.toLocaleString('es-ES', { style: 'currency', currency: 'EUR' });
document.querySelector('#app').innerHTML = productos.map((p) => `
<article class="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<h3 class="font-semibold text-slate-900">${p.nombre}</h3>
<p class="mt-1 text-sm text-slate-600">${p.descripcion}</p>
<p class="mt-3 font-semibold tabular-nums text-indigo-700">${euros(p.precio)}</p>
</article>`).join('');

Figura 2. Tres tarjetas y una sola copia de las clases: el bucle ya ha eliminado la duplicación.
Las clases aparecen una vez y las tres tarjetas cambian a la vez. No hemos inventado ningún nombre ni hemos escrito CSS.
💡 Consejo: Tailwind lee este fichero
.jscomo texto y encuentra las clases, igual que en un HTML. Por eso todo el módulo funciona sin configurar nada. La condición es que las clases aparezcan completas en el código fuente.bg-${color}-500no funcionaría: el porqué se explica en el módulo 18.
El árbol de decisión
Junta todo lo anterior en tres preguntas:
Figura 3. La clase CSS es la última opción, no la primera.
La última casilla es importante: si tu proyecto es HTML puro, sin plantillas ni JavaScript, una clase en @layer components es una solución buena y honesta. No tienes otra forma de compartir esas clases. Lo mismo vale para contenido que no controlas, como el HTML que devuelve un gestor de contenidos.
3. Clases propias en @layer components 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4),
@layer componentsno es una capa nativa: Tailwind procesa esas clases, elimina las que no usas y les permite variantes comohover:boton. En v4 es una capa real de CSS: la clase se emite siempre y no admite variantes. Si usas la LTS: usa@layer componentsigual, pero sustituye las variables del tema, que no existen en 3.4, por@applyotheme('colors.indigo.600'); ganarás variantes y eliminación de clases sin uso, y no necesitarás@utility.
Una clase propia se escribe con CSS normal dentro de la capa components. Usa las variables del tema para no duplicar valores:
@import "tailwindcss";
@layer components {
.boton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--radius-lg);
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 4);
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
}
.boton-primario {
background-color: var(--color-indigo-600);
color: var(--color-white);
&:hover {
background-color: var(--color-indigo-800);
}
}
}
Por qué la capa importa
Vimos las capas en el módulo 02. Lo que nos interesa ahora es el orden: la capa utilities está por encima de components, y en CSS una capa posterior gana sea cual sea la especificidad.
Figura 4. Las utilidades vencen a tus componentes, y el CSS sin capa vence a las utilidades.
De esa regla salen tres consecuencias prácticas, que puedes ver en la figura 5:
<!-- Una utilidad sobrescribe el componente: funciona -->
<button class="boton boton-primario bg-emerald-700 rounded-full">Comprar</button>
<!-- Variantes sobre una clase de components: no generan nada -->
<button class="hover:boton md:boton-primario">Comprar</button>

Figura 5. Las utilidades mandan sobre .boton, hover:boton no produce nada (tercer ejemplo) y md:etiqueta, creada con @utility, sí funciona. Debajo, dos alertas hechas con @apply.
- Puedes personalizar un componente con utilidades. Es justo lo que quieres:
class="boton mt-4"siempre se comporta como esperas. - No puedes aplicar variantes a la clase.
md:boton-primariono existe, porque Tailwind no la considera una utilidad. El navegador muestra un texto normal, sin ningún error. - El CSS sin capa lo vence todo. Si escribes
.botonfuera de@layer, comprobado en el navegador,bg-emerald-700deja de surtir efecto. Es uno de los errores más desconcertantes al migrar CSS antiguo.
⚠️ Importante: Hay una diferencia sutil con el hover. Con
class="boton-primario bg-emerald-700", al pasar el ratón el botón sigue verde en v4, porque.bg-emerald-700está en una capa superior y gana también a.boton-primario:hover. En 3.4 las capas no son nativas y manda la especificidad:.boton-primario:hoverpesa más que.bg-emerald-700, de modo que el botón cambia al color del hover. Lo hemos medido en el navegador con ambas versiones.
Qué pierdes frente a una clase del tema
Compara lo que genera Tailwind para una clase que usas y otra que no:
@layer components {
.boton { padding: 1rem; color: red; }
.nousada { color: blue; }
}
En 4.3.3, las dos aparecen en el CSS final. Tailwind no las analiza: las copia tal cual a la capa components. Por eso, cada clase que dejes de usar seguirá pesando hasta que la borres. En 3.4, .nousada desaparece, porque Tailwind se apropia de la capa.
| Comportamiento | Tailwind 4.3.3 | Tailwind 3.4.19 |
|---|---|---|
@layer components | Capa nativa de CSS | La procesa Tailwind |
| Clase sin usar | Se queda en el CSS | Se elimina |
hover:boton, md:boton | No genera nada | Funciona |
@apply boton | Error | Funciona |
Una utilidad vence al :hover del componente | Sí | No: gana el :hover por especificidad |
4. @apply con mesura
@apply copia dentro de tu clase las declaraciones de una o varias utilidades. Es la forma más cómoda de escribir la clase anterior, porque usas los mismos valores que en el HTML y no tienes que recordar el nombre de las variables:
@layer components {
.alerta {
@apply rounded-lg border border-sky-300 bg-sky-50 p-4 text-sm text-sky-900;
@apply dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100;
}
.alerta-peligro {
@apply border-red-300 bg-red-50 text-red-900;
@apply dark:border-red-700 dark:bg-red-950 dark:text-red-100;
}
}
Las dos alertas de la figura 5 salen de este CSS. .alerta-peligro solo declara lo que cambia, y funciona porque se escribe después de .alerta en la misma capa.
Qué genera
@apply no enlaza con la utilidad: copia sus declaraciones. Este CSS:
.z {
@apply p-2 px-6 hover:bg-red-500 md:p-8 bg-blue-500;
}
produce esto en 4.3.3:
.z {
background-color: var(--color-blue-500);
padding: calc(var(--spacing) * 2);
padding-inline: calc(var(--spacing) * 6);
&:hover {
@media (hover: hover) {
background-color: var(--color-red-500);
}
}
@media (width >= 48rem) {
padding: calc(var(--spacing) * 8);
}
}
Observa tres cosas:
- Las variantes funcionan y se anidan dentro de la clase.
hover:lleva su@media (hover: hover), igual que en una utilidad. En 3.4 no hay esa condición: genera solo.z:hover.- El orden de
@applyno es el que escribes.bg-blue-500estaba al final y sale el primero. Tailwind ordena las declaraciones con sus propias reglas.@apply px-6 p-2da el mismo resultado que@apply p-2 px-6: ganapx-6, porquepadding-inlinese emite después depadding.
Eso último sorprende: dentro de @apply no puedes “corregir” un valor poniéndolo después.
.k {
@apply p-2!; /* padding: calc(var(--spacing) * 2) !important; */
}
El modificador ! también se copia. En 3.4 se escribe delante, @apply !p-2.
@apply de tus propias clases 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4),
@applyadmite cualquier clase definida en@layer components. En v4 solo admite utilidades, y falla con “Cannot apply unknown utility class”. Si usas la LTS: puedes escribir.aviso { @apply boton; }directamente; en v4, definebotoncon@utility.
@layer components {
.boton { padding: 1rem; }
.aviso { @apply boton; }
}
Cannot apply unknown utility class `boton`
Este error corta la compilación, y es una buena señal: @apply también te avisa si escribes mal una utilidad, algo que el atributo class nunca hará. En 3.4 el mensaje es The no-existe class does not exist.
¿Cuánto pesa @apply?
Se oye con frecuencia que @apply hincha el CSS. Vamos a medirlo en lugar de repetirlo. Hemos construido la misma página dos veces: 24 fichas con ocho piezas distintas (tarjeta, título, texto, insignia, campo, alerta y dos botones). Una versión usa utilidades en el HTML y la otra usa ocho clases con @apply. Son resultados reales de vite build, en bytes:
| Versión | CSS | CSS con gzip | HTML | HTML con gzip | Total con gzip |
|---|---|---|---|---|---|
| Utilidades | 9 336 | 2 673 | 26 410 | 789 | 3 462 |
@apply | 10 267 | 2 621 | 7 522 | 428 | 3 049 |
El resultado desmonta las dos versiones del tópico:
- El CSS sí crece con
@apply(un 10 % aquí, porque repite declaraciones), pero con gzip casi no se nota. - El HTML con utilidades es tres veces y media más grande sin comprimir, pero gzip lo reduce muchísimo, porque las clases se repiten.
- Con compresión, la diferencia total son unos 400 bytes.
Por tanto, el tamaño no debería decidir. Lo que importa es el mantenimiento y la forma de crecer: con utilidades, un componente nuevo solo añade las clases que aún no existían; con @apply, cada componente nuevo copia sus declaraciones.
Por qué con mesura
El verdadero coste de @apply es otro:
- Reintroduce lo que Tailwind eliminó: hay que inventar nombres y saltar entre dos ficheros para saber cómo se ve un elemento.
- Pierdes localidad: al borrar un componente, su clase se queda en el CSS, porque en v4 nada la elimina por ti.
- Mezcla dos sistemas: el orden real de las declaraciones deja de ser evidente, como acabas de ver.
Úsalo para lo que no puedes tocar con clases: contenido generado por Markdown, estilos de librerías de terceros o HTML puro sin plantillas.
/* Estilos para el HTML que devuelve el gestor de contenidos */
.contenido-articulo a {
@apply text-indigo-700 underline underline-offset-2 hover:text-indigo-900;
}
💡 Consejo: Si usas
@applyen una hoja aparte, como un CSS Module o el<style>de un componente de Vue, Tailwind no tiene el tema disponible en ese fichero. Hay que importarlo con@reference, que se explica en el módulo 15 y se aplica a frameworks en el módulo 18.
5. Componentes como utilidades con @utility 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: define la clase en
@layer components, que en 3.4 ya admite variantes comomd:etiquetay puede usarse en@apply.
Si necesitas una clase propia con variantes, en v4 hay que registrarla como utilidad. La directiva se explica a fondo en el módulo 15; aquí interesa su uso como pequeño componente:
@utility etiqueta {
display: inline-flex;
border-radius: var(--radius-full);
background-color: var(--color-indigo-100);
padding: calc(var(--spacing) * 0.5) calc(var(--spacing) * 3);
font-size: var(--text-sm);
color: var(--color-indigo-800);
}
Ahora etiqueta, hover:etiqueta y md:etiqueta funcionan, se eliminan si no se usan y admiten @apply etiqueta. Pero entonces, si está en la capa utilities, ¿sigue pudiendo sobrescribirse con p-2 o bg-red-500?
Cómo ordena Tailwind tus utilidades
Dentro de la capa no decide tu orden de escritura. Tailwind ordena por las propiedades que declara cada utilidad y, a igualdad, coloca antes la que declara más. Compilando varias utilidades propias junto a las del tema, este es el orden real que resulta en 4.3.3:
@utility aviso { color: green; }
@utility zafiro { color: navy; }
@utility btn { display: inline-flex; padding: 1rem; border-radius: 2px; }
@utility btn-peligro { background-color: red; color: white; }
@utility btn-primario { background-color: blue; color: white; }
@utility btn-sm { padding: 0.25rem; font-size: 0.875rem; }
btn < flex < hidden < btn-peligro < btn-primario < bg-red-500 < btn-sm < p-2 < aviso < text-blue-500 < zafiro
Las conclusiones son útiles:
- Una utilidad de varias propiedades pierde frente a las del tema.
btnva al principio, de modo quep-2yflexla sobrescriben. Se comporta como un componente. btn-smqueda después debtn, por lo que puede cambiar su relleno.- Dos utilidades con las mismas propiedades se desempatan por orden alfabético. Entre
btn-peligroybtn-primarioganabtn-primario. Es otro motivo para no poner nunca dos variantes a la vez. - Hay una trampa con las utilidades de una sola propiedad.
avisoqueda antes detext-blue-500, perozafiroqueda después. Conclass="zafiro text-blue-500"ganazafiro, y solo por su nombre.
⚠️ Importante: Si una utilidad propia tiene una sola propiedad, puede vencer a las del tema según cómo se llame. Para un componente, de varias propiedades, esto no ocurre. Si quieres garantizar que cualquier utilidad lo sobrescriba, usa
@layer components.
Cuándo usar cada una
| Necesidad | Tailwind 4 | Tailwind 3.4 |
|---|---|---|
| Componente sobrescribible, sin variantes | @layer components | @layer components |
Variantes como md:clase | @utility | @layer components |
Usarla en @apply | @utility | @layer components |
| Que desaparezca si no se usa | @utility | @layer components |
6. El problema de los conflictos
Hasta aquí hemos reutilizado con CSS. Con componentes en JavaScript aparece un problema nuevo, que se ve bien con un ejemplo real. Tienes un botón con clases base y quieres que quien lo use pueda ajustarlo:
const base = 'rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white';
const extra = 'rounded-full bg-emerald-700 px-2';
const clases = `${base} ${extra}`;
Parece lógico: las clases de extra van después, así que deberían ganar. Este es el resultado real:

Figura 6. Concatenar no cambia nada: el segundo botón sigue en índigo, con relleno de 16 px y esquinas de 6 px. El tercero usa cn(), que construiremos enseguida.
Medido en el navegador, el botón concatenado conserva padding-left: 16px y el fondo índigo. Ninguna de las tres clases “nuevas” ha tenido efecto.
📘 Concepto: En
class="a b", el orden del atributo no influye. Lo que decide es el orden de las reglas en la hoja de estilos, que elige Tailwind. Entre utilidades de la misma propiedad, y según vimos en los módulos 02 y 07, v4 ordena por valor o alfabéticamente y v3 lo hace como texto.bg-emerald-700va antes quebg-indigo-600, y por eso pierde.
Lo peor es que a veces funciona por casualidad: px-4 px-10 daría 40 px en v4, porque px-10 tiene mayor valor, y en 3.4 ganaría px-4. Un componente no puede depender de eso.
Hay tres soluciones, y las dos primeras no bastan:
- Usar
!:bg-emerald-700!gana, pero inicia una escalada en la que no se puede sobrescribir nada sin más!. - Prever cada opción con una variante:
boton({ redondo: true }). Es la mejor opción para lo habitual, pero no cubre los ajustes puntuales, como un margen o un ancho. - Eliminar del atributo las clases que pierden: si solo queda
px-2, no hay conflicto. Es lo que hacetailwind-merge.
Las secciones siguientes construyen esa solución pieza a pieza:
Figura 7. Tres herramientas pequeñas y una cadena de texto al final.
7. clsx: clases condicionales
clsx resuelve un problema muy simple: construir una cadena de clases a partir de condiciones, sin espacios sobrantes ni la palabra false en el atributo.
import { clsx } from 'clsx';
clsx('a', false, null, undefined, 0, '', 'b'); // "a b"
clsx('a', { b: true, c: false }, ['d', ['e', { f: 1 }]]); // "a b d e f"
clsx(1, 2); // "1 2"
Admite cadenas, objetos (la clave entra si su valor es verdadero) y arrays anidados. Ignora cualquier valor falso. Ten cuidado con los números: 0 se descarta, pero cualquier otro se convierte en clase.
La comparación con lo que escribirías sin él explica por qué se usa tanto:
// Sin clsx: espacios dobles y plantillas difíciles de leer
const c1 = `boton ${activo ? 'bg-indigo-600' : ''} ${grande ? 'text-lg' : ''}`;
// Con clsx
const c2 = clsx('boton', activo && 'bg-indigo-600', grande && 'text-lg');
// Con un objeto, cómodo cuando hay muchas condiciones
const c3 = clsx('boton', {
'bg-indigo-600': activo,
'text-lg': grande,
});
Pesa muy poco: el fichero dist/clsx.mjs ocupa 248 bytes con gzip. Si solo usas cadenas y condiciones, clsx/lite es aún más sencillo.
⚠️ Importante:
clsxno sabe nada de Tailwind.clsx('px-4', 'px-2')devuelve"px-4 px-2", con el conflicto intacto.
8. tailwind-merge: resolver conflictos 🔄 v4.0
🔄 Cambia en Tailwind 4.0.
tailwind-merge3 solo admite Tailwind 4 (hasta 4.3 en la versión 3.7.0) y la LTS (Tailwind 3.4) necesita la rama 2. Si usas la LTS: instalatailwind-merge@2; la APItwMergees la misma, pero no entiende clases de v4 y configurar tus tokens se hace de otro modo (ver más abajo).
twMerge recibe una o varias cadenas y elimina las clases que quedan anuladas por otra posterior:
import { twMerge } from 'tailwind-merge';
twMerge('px-4 py-2 bg-indigo-600', 'px-6');
// "py-2 bg-indigo-600 px-6"
Lo que hace es conocer la gramática de Tailwind: sabe que px-4 y px-6 fijan lo mismo. Conserva la última y borra las anteriores. Esta tabla recoge salidas reales:
| Entrada | Salida | Por qué |
|---|---|---|
p-4, px-6 | p-4 px-6 | px-6 solo pisa una parte de p-4 |
px-6, p-4 | p-4 | p-4 cubre px-6 por completo |
hover:bg-red-500 bg-red-500, bg-blue-500 | hover:bg-red-500 bg-blue-500 | Las variantes forman grupos aparte |
md:px-4 hover:md:px-4, md:hover:px-6 | md:px-4 md:hover:px-6 | Entiende las variantes en cualquier orden |
text-lg text-red-500, text-blue-500 | text-lg text-blue-500 | Distingue tamaño y color |
text-sm, text-lg/7 | text-lg/7 | Mismo grupo de tamaño |
leading-9 text-lg | text-lg | text-lg ya fija la altura de línea |
text-lg leading-9 | text-lg leading-9 | Aquí leading-9 va después y se respeta |
rounded-t-none rounded-lg | rounded-lg | Lo general anula lo parcial anterior |
bg-red-500 bg-[#123456] | bg-[#123456] | Admite valores arbitrarios |
flex hidden | hidden | Mismo grupo de display |
shadow-md shadow-red-500 | shadow-md shadow-red-500 | Tamaño y color de sombra son independientes |
mt-2!, mt-4 | mt-2! mt-4 | Lo importante forma un grupo aparte |
btn btn-primario px-6 px-8 | btn btn-primario px-8 | Lo que no conoce lo deja intacto |
El asistente cn()
clsx y twMerge se complementan tan bien que casi todos los proyectos los juntan en una función de una línea, que se suele llamar cn:
// lib/cn.js
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
// Une clases condicionales y resuelve conflictos: la última gana
export function cn(...entradas) {
return twMerge(clsx(...entradas));
}
Con ella, el ejemplo de la figura 6 ya funciona:
cn('rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white',
'rounded-full bg-emerald-700 px-2');
// "py-2 text-sm font-medium text-white rounded-full bg-emerald-700 px-2"
Se quedan fuera rounded-md, bg-indigo-600 y px-4. El CSS no cambia: lo que cambia es el atributo.
Cuando no entiende tus tokens
tailwind-merge no lee tu CSS. Conoce las escalas por defecto y debe adivinar el resto. La mayoría de las veces acierta, pero hay un caso peligroso. Supón que tienes este tamaño de letra propio:
@theme {
--text-titulo: 1.375rem;
--text-titulo--line-height: 1.3;
}
twMerge('text-titulo', 'text-red-700'); // "text-red-700"
twMerge('text-titulo', 'text-lg'); // "text-titulo text-lg"
Los dos resultados son incorrectos. Ante text-titulo, la librería supone que es un color, porque no lo encuentra entre los tamaños. En el primer caso borra tu tamaño de letra al añadir un color; en el segundo deja dos tamaños juntos. Y no da ningún aviso. La solución es decírselo:
// lib/cn.js
import { clsx } from 'clsx';
import { extendTailwindMerge } from 'tailwind-merge';
const miTwMerge = extendTailwindMerge({
extend: {
theme: {
text: ['titulo'],
},
},
});
export function cn(...entradas) {
return miTwMerge(clsx(...entradas));
}
miTwMerge('text-titulo', 'text-red-700'); // "text-titulo text-red-700"
miTwMerge('text-titulo', 'text-lg'); // "text-lg"
Los colores propios dan menos problemas: bg-marca bg-acento se resuelve bien, porque bg-* desconocido se toma como color. También puedes registrar grupos para tus propias clases:
const miTwMerge = extendTailwindMerge({
extend: {
classGroups: {
tamanoBoton: ['btn-sm', 'btn-lg'],
},
},
});
miTwMerge('btn-sm btn-lg'); // "btn-lg"
⚠️ Importante: Siempre que añadas a
@themeun nombre que comparta prefijo con otra familia (sobre todo tamaños entext-*), configúralo aquí. Es el error más difícil de detectar de este módulo, porque solo se nota en los elementos donde coinciden las dos clases.
Configuración en la rama 2 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4),
tailwind-merge2 no tiene la clavetheme.text. Si usas la LTS: registra tu tamaño en el grupofont-size, como en el código de esta sección; el resultado es el mismo que en la rama 3.
import { extendTailwindMerge } from 'tailwind-merge'; // versión 2.6
const miTwMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [{ text: ['titulo'] }],
},
},
});
miTwMerge('text-titulo', 'text-red-700'); // "text-titulo text-red-700"
miTwMerge('text-titulo', 'text-lg'); // "text-lg"
La rama 2 tampoco entiende la sintaxis de v4 que no existe en v3. Por ejemplo, mask-radial-from-50% mask-radial-from-20% o bg-linear-to-r bg-gradient-to-r se quedan sin fusionar. Ten presente que usar la rama equivocada no da error: simplemente deja conflictos sin resolver.
Cuánto cuesta
tailwind-merge es una librería de verdad, no un truco de una línea. El fichero dist/bundle-mjs.mjs ocupa unos 18 KB con gzip, frente a 248 bytes de clsx. Además, se ejecuta en el navegador cada vez que calcula unas clases, aunque guarda en caché los resultados.
Por eso, úsalo solo donde hay conflictos posibles:
- Sí, en componentes reutilizables que aceptan clases desde fuera.
- No, en clases fijas que tú mismo escribes y que no pueden chocar. Ahí basta
clsx, o nada.
9. class-variance-authority: variantes declarativas
Un botón con tres dimensiones escrito con condiciones se convierte enseguida en un laberinto de if. cva cambia el planteamiento: en vez de programar cómo se calculan las clases, describes las opciones como datos.
import { cva } from 'class-variance-authority';
const boton = cva('inline-flex font-medium', {
variants: {
variante: {
primario: 'bg-indigo-600 text-white',
fantasma: 'bg-transparent text-indigo-700',
},
tamano: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
},
bloque: {
true: 'w-full',
},
},
compoundVariants: [
{ variante: 'fantasma', tamano: 'sm', class: 'underline' },
{ variante: ['primario', 'fantasma'], bloque: true, className: 'justify-center' },
],
defaultVariants: {
variante: 'primario',
tamano: 'md',
},
});
cva() devuelve una función. Al llamarla, eliges una opción de cada variante:
boton();
// "inline-flex font-medium bg-indigo-600 text-white px-4 py-2"
boton({ variante: 'fantasma', tamano: 'sm' });
// "inline-flex font-medium bg-transparent text-indigo-700 px-3 py-1.5 text-sm underline"
boton({ bloque: true });
// "inline-flex font-medium bg-indigo-600 text-white px-4 py-2 w-full justify-center"
Cada parte tiene una función concreta:
| Clave | Función |
|---|---|
| Primer argumento | Clases que se aplican siempre |
variants | Dimensiones y sus opciones. Las clases se escriben completas, para que Tailwind las detecte |
compoundVariants | Clases que solo se añaden cuando coinciden varias opciones. Admite arrays, y class o className |
defaultVariants | Opción por defecto si no se indica ninguna |
Las opciones true y false crean variantes booleanas, como bloque.
Tres comportamientos que conviene conocer
boton({ variante: undefined }); // usa el valor por defecto: bg-indigo-600 text-white
boton({ variante: null }); // quita la variante: "inline-flex font-medium px-4 py-2"
boton({ variante: 'noexiste' }); // tampoco pone nada, y sin avisar
El último es peligroso: un error tipográfico produce un botón sin color y sin mensaje alguno. En JavaScript puro no hay más defensa que probarlo; con TypeScript, el compilador lo detecta.
Tipos con TypeScript
VariantProps extrae los tipos de la receta, de forma que no tienes que escribirlos dos veces:
import { cva, type VariantProps } from 'class-variance-authority';
const receta = cva('inline-flex', {
variants: {
variante: { primario: 'bg-indigo-600', peligro: 'bg-red-700' },
bloque: { true: 'w-full' },
},
});
type PropsBoton = VariantProps<typeof receta> & { clase?: string };
const ok: PropsBoton = { variante: 'peligro', bloque: true, clase: 'mt-2' };
receta({ variante: 'rojo' }); // Error de compilación
cva no resuelve conflictos
Este detalle se pasa por alto a menudo:
const c = cva('px-4 bg-indigo-600', { variants: { ancho: { si: 'px-8' } } });
c({ ancho: 'si' }); // "px-4 bg-indigo-600 px-8"
c({ class: 'px-2' }); // "px-4 bg-indigo-600 px-2"
Quedan dos rellenos. cva solo une cadenas, como clsx. Hay dos formas de resolverlo:
- Diseñar la receta para que base y variantes no compitan: nada de
px-*en la base si los tamaños también lo fijan. - Envolver la receta con
cn(), que es lo que haremos en la sección 11.
Lo mejor es hacer las dos cosas.
10. tailwind-variants: receta, slots y fusión 🔄 v4.0
🔄 Cambia en Tailwind 4.0.
tailwind-variants1 y posteriores están pensadas para Tailwind 4 (la versión 1.0 ya depende detailwind-merge3). En la LTS (Tailwind 3.4) corresponde la 0.3.1, que incluyetailwind-merge2.5.4. Si usas la LTS: instalatailwind-variants@0.3;tv(), slots yextendse usan igual, pero no hay entrada/configycnfunciona en dos pasos:cn('px-2', 'px-4')({ twMerge: true }).
tailwind-variants (abreviado tv) sigue la misma idea que cva, con tres diferencias importantes: resuelve los conflictos, reparte una receta entre varios elementos y permite heredar de otra receta.
import { tv } from 'tailwind-variants';
const boton = tv({
base: 'inline-flex font-medium',
variants: {
variante: {
primario: 'bg-indigo-600 text-white',
fantasma: 'bg-transparent text-indigo-700',
},
tamano: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
},
},
compoundVariants: [
{ variante: 'fantasma', tamano: 'sm', class: 'underline' },
],
defaultVariants: {
variante: 'primario',
tamano: 'md',
},
});
boton({ class: 'px-8' });
// "inline-flex font-medium bg-indigo-600 text-white py-2 px-8"
Comparando con cva, px-4 ha desaparecido sin hacer nada más.
📘 Concepto: La versión 4 de
tailwind-variantsno depende detailwind-merge: su paquete no declara dependencias y trae su propio motor de fusión. Exportacn,cx,twMerge,twJoinyclsx, de modo que un proyecto que usetvno necesita instalar nada más. Si ya tienestailwind-merge, no mezcles dos motores:tailwind-variants/liteno incluye ninguno y te deja pasar el tuyo.
import { cn, cx } from 'tailwind-variants';
cn('px-2', 'px-4', { 'py-1': true }); // "px-4 py-1"
cx('px-2', 'px-4'); // "px-2 px-4"
Tokens propios en tv
El motor propio tiene el mismo problema con los tokens que vimos en tailwind-merge, y su configuración vive en otra entrada del paquete. Si pasas twMergeConfig a la entrada normal, obtienes un error explícito al definir la receta:
[tailwind-variants] `twMergeConfig` is only supported by the `tailwind-variants/config` entry.
Hay que importar desde /config:
import { tv } from 'tailwind-variants/config';
const titulo = tv(
{ base: 'text-titulo', variants: { grande: { true: 'text-lg' } } },
{ twMergeConfig: { extend: { theme: { text: ['titulo'] } } } },
);
titulo({ grande: true }); // "text-lg"
Con la entrada normal y sin configuración, el resultado sería "text-titulo text-lg", con dos tamaños.
Slots: una receta para varios elementos
cva describe un elemento. Pero una tarjeta son varios elementos que cambian a la vez: si está destacada, cambian el borde de la raíz y el color del título. Los slots (ranuras) dan nombre a cada parte:
const tarjeta = tv({
slots: {
raiz: 'rounded-xl border',
titulo: 'font-semibold',
cuerpo: 'p-4',
},
variants: {
destacada: {
true: { raiz: 'border-indigo-500', titulo: 'text-indigo-700' },
},
compacta: {
true: { cuerpo: 'p-2' },
},
},
compoundSlots: [
{ slots: ['titulo', 'cuerpo'], class: 'text-pretty' },
],
compoundVariants: [
{ destacada: true, compacta: true, class: { titulo: 'text-sm' } },
],
});
Al llamarla, ya no devuelve una cadena sino un objeto con una función por slot (más base, que siempre existe):
const t = tarjeta({ destacada: true, compacta: true });
t.raiz(); // "rounded-xl border border-indigo-500"
t.titulo(); // "font-semibold text-indigo-700 text-sm text-pretty"
t.cuerpo(); // "p-2 text-pretty"
t.cuerpo({ class: 'p-8' }); // "text-pretty p-8"
t.titulo({ compacta: false }); // "font-semibold text-indigo-700 text-pretty"
Una variante escribe en varios slots a la vez y cada función acepta ajustes propios. compoundSlots aplica unas clases a varios slots, y compoundVariants, usado con un objeto, permite elegir en cuál escribe.
Herencia con extend
const botonPill = tv({
extend: boton,
base: 'rounded-full',
variants: {
variante: {
peligro: 'bg-red-600 text-white',
primario: 'bg-emerald-600',
},
},
});
botonPill();
// "inline-flex font-medium rounded-full text-white bg-emerald-600 px-4 py-2"
botonPill({ variante: 'peligro' });
// "inline-flex font-medium rounded-full bg-red-600 text-white px-4 py-2"
La hija añade una opción (peligro) y cambia otra (primario). Las dos clases de bg-* que chocarían se resuelven solas.
⚠️ Importante: Las variantes responsive (
tamano: { initial: 'sm', md: 'lg' }), que ves en muchos tutoriales antiguos, ya no existen. El propio README de la librería explica que dependían deconfig.content.transform, eliminado en Tailwind 4. Lo hemos comprobado: en 4.0.1 no se aplica nada y no hay error. Escribe las clases responsive directamente:'text-sm md:text-lg'.
¿cva o tv?
| Criterio | cva + cn | tailwind-variants |
|---|---|---|
| Variantes, compuestas y por defecto | Sí | Sí |
| Resolución de conflictos | Con tailwind-merge | Incluida |
| Varios elementos (slots) | No: una receta por elemento | Sí |
| Herencia | Manual, combinando funciones | extend |
| Tipos de TypeScript | VariantProps | VariantProps |
| Tamaño de la receta | 1,2 KB con gzip | Mayor, con motor de fusión incluido (hay entrada /lite) |
| Más adecuado para | Componentes de un solo elemento | Componentes compuestos |
Ninguna es mejor en general. En este módulo usaremos las dos a propósito: cva para el botón y las insignias, y tv para la tarjeta. En un proyecto real elige una para no tener dos estilos de código y dos motores de fusión.
💡 Consejo: Para ordenar las clases dentro de estas funciones, añádelas a Prettier (lo viste en el módulo 01):
"tailwindFunctions": ["cn", "cva", "tv"]. Lo hemos probado y ordena también las cadenas dentro devariants, de los arrays y deslots. Para el autocompletado, usa la misma lista en el ajustetailwindCSS.classFunctionsde VS Code.
11. Sistema de botones
Vamos a juntarlo todo en un componente real. Antes de escribir, hay que decidir las dimensiones:
- Variante, lo que comunica: primario, secundario, fantasma y peligro.
- Tamaño:
sm,mdylg. - Forma: normal, solo icono y bloque.
- Estado, que no se pasa como opción: lo aporta el HTML (
disabled,aria-busy,aria-disabled).
Esa última decisión es la más importante. Los estados no son variantes de la receta porque ya existen en HTML. Usar el atributo real da a la vez el estilo y el comportamiento, y el lector de pantalla se entera.
// componentes/boton.js
import { cva } from 'class-variance-authority';
import { cn } from '../lib/cn.js';
const recetaBoton = cva(
[
'inline-flex items-center justify-center gap-2 whitespace-nowrap',
'rounded-lg border font-medium cursor-pointer',
'transition-colors motion-reduce:transition-none',
'focus-visible:outline-2 focus-visible:outline-offset-2',
'focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-400',
'disabled:pointer-events-none disabled:opacity-50',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
'aria-busy:pointer-events-none',
],
{
variants: {
variante: {
primario: [
'border-transparent bg-indigo-600 text-white',
'hover:bg-indigo-700 active:bg-indigo-800',
'dark:bg-indigo-500 dark:hover:bg-indigo-600 dark:active:bg-indigo-700',
],
secundario: [
'border-slate-300 bg-white text-slate-800',
'hover:bg-slate-100 active:bg-slate-200',
'dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100',
'dark:hover:bg-slate-700 dark:active:bg-slate-600',
],
fantasma: [
'border-transparent bg-transparent text-indigo-700',
'hover:bg-indigo-50 active:bg-indigo-100',
'dark:text-indigo-300 dark:hover:bg-indigo-950 dark:active:bg-indigo-900',
],
peligro: [
'border-transparent bg-red-700 text-white',
'hover:bg-red-800 active:bg-red-900',
'focus-visible:outline-red-700 dark:focus-visible:outline-red-400',
],
},
tamano: {
sm: 'min-h-8 px-3 text-sm',
md: 'min-h-10 px-4 text-sm',
lg: 'min-h-12 px-6 text-base',
},
soloIcono: {
true: 'px-0',
},
bloque: {
true: 'w-full',
},
},
compoundVariants: [
{ soloIcono: true, tamano: 'sm', class: 'w-8' },
{ soloIcono: true, tamano: 'md', class: 'w-10' },
{ soloIcono: true, tamano: 'lg', class: 'w-12' },
],
defaultVariants: {
variante: 'primario',
tamano: 'md',
},
},
);
// Función pública: variantes de la receta y `clase` para ajustes puntuales
export function boton({ clase, ...variantes } = {}) {
return cn(recetaBoton(variantes), clase);
}
Merece la pena comentar varias decisiones:
- Se usa
min-h-*en lugar depy-*. La altura deja de depender de la fuente y del icono, y los tres tamaños miden exactamente 32, 40 y 48 px. - Las clases van en arrays. cva los acepta, y agrupan por tema: disposición, foco, estados. Es solo legibilidad.
- Todos tienen
border, aunque sea transparente. Así los cuatro miden lo mismo, y además tiene un efecto en accesibilidad que verás en la sección 14. peligroredefine el color del contorno. Al ser posterior,cnelimina de la basefocus-visible:outline-indigo-600y su versióndark:, y solo quedan las rojas. Sintailwind-merge, ganaría la que Tailwind escribiera después en la hoja, que no es necesariamente la que queremos.soloIconousa variantes compuestas, porque el ancho depende del tamaño.cursor-pointerhace falta en v4: los botones volvieron acursor: default, como viste en el módulo 02.
Su uso resulta muy limpio, y clase permite ajustes concretos:
boton({ variante: 'peligro', tamano: 'sm' });
boton({ clase: 'mt-4' });
boton({ variante: 'peligro', tamano: 'sm', clase: 'rounded-full px-2' });
// Resultado real: quedan bg-red-700, rounded-full y px-2; se eliminan rounded-lg y px-3
Esta es la matriz completa, generada con dos bucles sobre las variantes:
const vs = ['primario', 'secundario', 'fantasma', 'peligro'];
const ts = ['sm', 'md', 'lg'];
const filas = vs.flatMap((v) =>
ts.map((t) => `<button type="button" class="${boton({ variante: v, tamano: t })}">${icono}Añadir</button>`),
);

Figura 8. Cuatro variantes por tres tamaños: doce combinaciones de una sola receta.

Figura 9. El modo oscuro está dentro de cada variante, así que quien usa el botón no tiene que pensar en él.
Estados, enlaces e iconos
<!-- Deshabilitado: el atributo desactiva el botón y aporta el estilo -->
<button type="button" disabled class="...">Guardar</button>
<!-- Ocupado: sigue en la página y el lector lo anuncia -->
<button type="button" aria-busy="true" class="...">
<svg class="size-4 shrink-0 motion-safe:animate-spin" aria-hidden="true">...</svg>
Guardando…
</button>
<!-- Un enlace con aspecto de botón: la misma función, otra etiqueta -->
<a href="/ficha" class="...">Ver ficha</a>
<!-- Solo icono: el nombre accesible lo aporta aria-label -->
<button type="button" aria-label="Añadir al carrito" class="...">
<svg class="size-4 shrink-0" aria-hidden="true">...</svg>
</button>

Figura 10. Los estados los dan los atributos de HTML. El foco por teclado se ve claramente separado del botón gracias a outline-offset-2.

Figura 11. Estados en oscuro. El contorno de foco cambia a indigo-400 para seguir viéndose sobre el fondo.
Devolver solo clases es lo que hace posible el enlace: boton() no sabe qué etiqueta usarás. Esa libertad exige que elijas bien:
📘 Concepto: Si te lleva a otra página, es un
<a>. Si hace algo en la página, es un<button>. El aspecto no decide. Un<div class="boton">no recibe el foco, no responde al teclado y no se anuncia como botón.
Un detalle sobre disabled: <a> no lo admite. Para un enlace desactivado, usa aria-disabled="true". Nuestra receta le aplica pointer-events-none y la opacidad, pero el teclado aún podría activarlo: quita también el href o añade tabindex="-1".
La versión para Tailwind 3.4 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4),
focus-visible:outline-2solo fija el ancho: no poneoutline-style, y el contorno no se vería. Si usas la LTS: añadefocus-visible:outlinea la base de la receta; el resto compila igual, aunque los colores yshadow-smcambian de valor.
Hemos compilado el sistema de verdad con 3.4.19, tailwind-merge 2.6.1 y tailwind-variants 0.3.1. El único cambio en la receta es esta línea:
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
En lib/cn.js no hay nada que tocar, salvo la versión instalada. Con esa línea, el contorno medido en el navegador es solid de 2 px.

Figura 12. El mismo sistema compilado con Tailwind 3.4.19, con el foco en el primer botón.
12. Tarjetas
Una tarjeta de producto es el caso ideal para usar slots: tiene varias piezas que cambian juntas.
Figura 13. Cada parte de la tarjeta es un slot con nombre.
// componentes/tarjeta.js
import { tv } from 'tailwind-variants';
export const tarjeta = tv({
slots: {
raiz: [
'relative flex overflow-hidden rounded-xl border bg-white shadow-sm',
'border-slate-200 dark:border-slate-700 dark:bg-slate-900',
'transition-shadow hover:shadow-md motion-reduce:transition-none',
'has-[a:focus-visible]:outline-2 has-[a:focus-visible]:outline-offset-2',
'has-[a:focus-visible]:outline-indigo-600 dark:has-[a:focus-visible]:outline-indigo-400',
],
media: 'grid shrink-0 place-items-center bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500',
cuerpo: 'flex min-w-0 flex-1 flex-col gap-2 p-4',
titulo: 'font-semibold text-pretty text-slate-900 dark:text-white',
enlace: 'after:absolute after:inset-0 focus-visible:outline-none',
descripcion: 'line-clamp-2 text-sm text-slate-600 dark:text-slate-300',
pie: 'mt-auto flex items-center justify-between gap-2 pt-2',
precio: 'font-semibold tabular-nums text-slate-900 dark:text-white',
},
variants: {
orientacion: {
vertical: { raiz: 'flex-col', media: 'aspect-video w-full' },
horizontal: { raiz: 'flex-row', media: 'w-32' },
},
destacada: {
true: {
raiz: 'border-indigo-500 ring-1 ring-indigo-500 dark:border-indigo-400 dark:ring-indigo-400',
titulo: 'text-indigo-800 dark:text-indigo-200',
},
},
agotada: {
true: {
media: 'opacity-50 grayscale',
precio: 'text-slate-500 line-through dark:text-slate-400',
},
},
},
defaultVariants: { orientacion: 'vertical' },
});
La plantilla solo tiene que colocar cada función en su sitio. Fíjate en que agotada se deriva de los datos, en vez de pedírselo a quien la usa:
const ficha = (p, opciones = {}) => {
const t = tarjeta({ agotada: p.stock === 0, ...opciones });
const estado = p.stock === 0
? `<span class="${insignia({ tono: 'peligro' })}">Agotado</span>`
: `<span class="${insignia({ tono: 'exito' })}">En stock</span>`;
return `<article class="${t.raiz()}">
<div class="${t.media()}">${icono}</div>
<div class="${t.cuerpo()}">
<h3 class="${t.titulo()}"><a class="${t.enlace()}" href="/producto">${p.nombre}</a></h3>
<p class="${t.descripcion()}">${p.descripcion}</p>
<div class="${t.pie()}">
<span class="${t.precio()}">${euros(p.precio)}</span>${estado}
</div>
</div>
</article>`;
};

Figura 14. Las variantes destacada, agotada y orientacion en acción. El pie queda alineado gracias a mt-auto, como viste en flexbox.

Figura 15. Tema oscuro, con los mismos componentes.
Toda la tarjeta clicable sin romper el HTML
Lo tentador sería envolver la tarjeta entera en un <a>. Es válido en HTML, pero un lector de pantalla leerá de golpe título, descripción, precio y estado como nombre del enlace, y además te impide poner otro botón dentro.
La técnica correcta deja el enlace en el título, que es lo que lo describe, y estira su zona de clic con un pseudoelemento:
raiz: relative (referencia de posición)
enlace: after:absolute after:inset-0 (cubre toda la raíz)
El ::after del enlace forma parte del enlace, así que un clic en cualquier punto de la tarjeta lo activa. Si necesitas un botón dentro, como “Añadir al carrito”, dale relative z-10 para sacarlo por encima de esa capa.
Queda un problema: el contorno de foco rodearía solo el título, y parecería que la tarjeta no está seleccionada. La solución tiene dos partes: focus-visible:outline-none en el enlace y has-[a:focus-visible]:outline-2 en la raíz, que dibuja el contorno cuando hay dentro un enlace con foco de teclado.

Figura 16. Con el tabulador en el segundo enlace, se resalta la tarjeta entera.
💡 Consejo: Si quieres un efecto en la imagen al pasar por la tarjeta, añade
grouparaizygroup-hover:scale-105amedia, como viste en el módulo 11. Añádelo solo si lo usas: las clases que no hacen nada confunden a quien lee el código.
13. Badges
Las insignias (badges) parecen el componente más simple, pero enseñan dos cosas: cuándo las variantes compuestas son imprescindibles y por qué el color no basta.
Aquí hay dos dimensiones independientes y el resultado depende de ambas: un tono exito suave no comparte ninguna clase con uno sólido. Por eso, variants queda vacío y el trabajo real está en compoundVariants:
// componentes/insignia.js
import { cva } from 'class-variance-authority';
export const insignia = cva(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap ring-1 ring-inset',
{
variants: {
tono: { neutro: '', info: '', exito: '', aviso: '', peligro: '' },
relleno: { suave: '', solido: 'ring-transparent' },
},
compoundVariants: [
{ tono: 'neutro', relleno: 'suave', class: 'bg-slate-100 text-slate-700 ring-slate-300 dark:bg-slate-800 dark:text-slate-200 dark:ring-slate-600' },
{ tono: 'info', relleno: 'suave', class: 'bg-sky-50 text-sky-800 ring-sky-300 dark:bg-sky-950 dark:text-sky-200 dark:ring-sky-700' },
{ tono: 'exito', relleno: 'suave', class: 'bg-emerald-50 text-emerald-800 ring-emerald-300 dark:bg-emerald-950 dark:text-emerald-200 dark:ring-emerald-700' },
{ tono: 'aviso', relleno: 'suave', class: 'bg-amber-50 text-amber-800 ring-amber-300 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-700' },
{ tono: 'peligro', relleno: 'suave', class: 'bg-red-50 text-red-800 ring-red-300 dark:bg-red-950 dark:text-red-200 dark:ring-red-700' },
{ tono: 'neutro', relleno: 'solido', class: 'bg-slate-700 text-white' },
{ tono: 'info', relleno: 'solido', class: 'bg-sky-700 text-white' },
{ tono: 'exito', relleno: 'solido', class: 'bg-emerald-700 text-white' },
{ tono: 'aviso', relleno: 'solido', class: 'bg-amber-300 text-amber-950' },
{ tono: 'peligro', relleno: 'solido', class: 'bg-red-700 text-white' },
],
defaultVariants: { tono: 'neutro', relleno: 'suave' },
},
);
Las opciones vacías no son un error: declaran qué valores existen, y con TypeScript dan el autocompletado. Observa que aviso sólido usa fondo claro y texto oscuro. Un color no siempre admite el mismo tratamiento que los demás, y por eso cada combinación se escribe a mano.
💡 Consejo: ¿Por qué no ahorrar líneas con
bg-${tono}-50? Porque Tailwind busca clases completas en tu código y esa cadena no aparece en ningún sitio. Copiar diez líneas es feo, pero funciona, y buscarbg-emerald-50en el proyecto te llevará directamente aquí. Lo verás con detalle en el módulo 18.
De los datos a la interfaz
En la tienda, cada pedido tiene un estado en mayúsculas (ENTREGADO, ENVIADO, PENDIENTE, CANCELADO). No conviene traducirlo a un color en cada pantalla. Mejor, centraliza esa decisión:
export const estadosPedido = {
PENDIENTE: { tono: 'aviso', texto: 'Pendiente', icono: '○' },
ENVIADO: { tono: 'info', texto: 'Enviado', icono: '→' },
ENTREGADO: { tono: 'exito', texto: 'Entregado', icono: '✓' },
CANCELADO: { tono: 'peligro', texto: 'Cancelado', icono: '×' },
};
const x = estadosPedido[pedido.estado];
`<span class="${insignia({ tono: x.tono })}">
<span aria-hidden="true">${x.icono}</span>${x.texto}
</span>`

Figura 17. Cinco tonos por dos rellenos, y el uso real en una lista de pedidos.

Figura 18. En tema oscuro, el relleno suave se invierte.
Cada estado tiene texto, icono y color. Una persona con daltonismo que no distinga verde y rojo sigue leyendo “Entregado” y “Cancelado”. El icono lleva aria-hidden="true" porque el texto ya lo dice y no queremos que el lector anuncie “marca de verificación Entregado”.
14. Accesibilidad de los componentes
Un componente concentra todas las repeticiones, de modo que un error se multiplica por cien, pero también un acierto. Vale la pena comprobarlo una vez y olvidarse.
Foco visible
- Usa
focus-visible:, nofocus:. El contorno aparece con teclado, pero no al hacer clic con el ratón. - Nunca quites el contorno sin poner otro. Si necesitas quitarlo, en v4 es mejor
outline-hidden, que añade un contorno transparente dentro de@media (forced-colors: active)y por eso reaparece en ese modo.outline-nonelo elimina del todo, como viste en el módulo 06. En nuestra tarjeta es aceptable solo porque la raíz dibuja otro contorno. outline-offset-2separa el contorno del botón. Sin esa separación, un contorno índigo sobre un botón índigo no se vería.
Contraste
El criterio 1.4.3 de WCAG 2.2 (nivel AA) pide 4,5:1 para el texto normal y 3:1 para el texto grande. Los controles desactivados están exentos, y por eso disabled:opacity-50 no es un incumplimiento. Medir es fácil: se lee el color calculado en el navegador, se convierte a sRGB y se aplica la fórmula.
| Componente | Claro | Oscuro |
|---|---|---|
| Botón primario | 6,46 | 4,58 |
| Botón secundario | 14,62 | 13,34 |
| Botón fantasma | 8,09 | 10,03 |
| Botón peligro | 6,42 | 6,42 |
| Insignia suave (la más baja) | 6,84 | 10,44 |
| Insignia sólida (la más baja) | 5,36 | 5,36 |
Todos superan el mínimo, medidos en 4.3.3. El primario en oscuro va justo, y ese es el motivo de medir: bg-indigo-500 pasa con poco margen, y cualquier retoque del color obligaría a repetir la comprobación. Recuerda que la paleta de 3.4 tiene valores distintos: esta tabla no se traslada a esa versión.
Tamaño de pulsación
El criterio 2.5.8 de WCAG 2.2 (nivel AA) pide objetivos de al menos 24 × 24 píxeles CSS, salvo excepciones como que haya suficiente separación entre ellos. Nuestros tamaños miden 32, 40 y 48 px gracias a min-h-8, min-h-10 y min-h-12.
Objetivos mayores en pantallas táctiles 🆕 v4.1
🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa una variante arbitraria,
[@media(pointer:coarse)]:min-h-11, que da el mismo resultado.
En un móvil puedes ampliar el objetivo solo donde hace falta, sin agrandar la interfaz de escritorio:
sm: 'min-h-8 px-3 text-sm pointer-coarse:min-h-11',
Colores forzados
Los modos de contraste alto (Windows, por ejemplo) sustituyen tus colores por los del sistema. Los fondos desaparecen y lo que no tenga borde se queda sin forma. Hemos simulado este modo con Chromium:

Figura 19. Los cinco ejemplos con colores normales.

Figura 20. Con colores forzados, el primer botón deja de parecer un botón y el punto verde desaparece por completo.
Las lecciones son claras:
border border-transparentno se ve normalmente, pero en este modo el navegador lo pinta. Es la razón de que el botón lleveborderen todas sus variantes.- Un estado indicado solo con un punto de color se pierde. Por eso la insignia incluye texto.
- Si necesitas ajustar algo, existe la variante
forced-colors:, por ejemploforced-colors:border-[ButtonText].
Movimiento reducido
motion-reduce:transition-nonequita las transiciones a quien lo ha pedido en su sistema.motion-safe:animate-spinhace lo contrario: la animación solo se activa si no hay esa preferencia. Lo viste en el módulo 13.
Lista de comprobación
Antes de dar por terminado un componente, revisa esto:
| Comprobación | Cómo |
|---|---|
| Se usa la etiqueta correcta | <button> para acciones, <a> para navegar |
| Se puede usar sin ratón | Recórrelo con Tab, Intro y Espacio |
| El foco se ve siempre | Tabula en claro y en oscuro |
| Tiene nombre accesible | Texto visible o aria-label si solo lleva icono |
| Los iconos decorativos están ocultos | aria-hidden="true" |
| El estado no depende solo del color | Añade texto o icono |
| El contraste es suficiente | Mide el color real con las herramientas del navegador |
| Se puede pulsar con el dedo | 24 px como mínimo y mejor más |
| Sobrevive a los colores forzados | Emula forced-colors en las herramientas del navegador |
Respeta prefers-reduced-motion | Usa motion-reduce: o motion-safe: |
15. Organizar el sistema
Tokens con significado 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe
@theme. Si usas la LTS: declara los mismos alias entheme.extend.colorsdetailwind.config.js, por ejemploprimario: '#4f46e5', y obtendrás las mismas clases.
Las recetas usan bg-indigo-600 por todas partes. Si la marca cambia a verde, tendrás que buscar y reemplazar en muchos sitios. Un alias semántico en @theme, como viste en el módulo 14, lo evita:
@theme {
--color-primario: var(--color-indigo-600);
--color-primario-hover: var(--color-indigo-700);
}
Esto genera bg-primario y hover:bg-primario-hover. Ahora el botón dice qué significa el color y no cuál es. Para tailwind-merge, los colores bg-* desconocidos ya funcionan sin configuración, como comprobamos en la sección 8.
Reglas para que el sistema aguante
- Una opción para cada decisión. Si
clasese usa siempre para lo mismo, conviértela en una variante. clasees para colocar, no para rediseñar. Margen, ancho oself-*son razonables, porque dependen del lugar. Cambiar el color desde fuera indica que falta una variante.- Los márgenes externos no van en el componente. Un botón con
mt-4de serie estorbará en todas partes salvo en una. Lo separa quien lo coloca. - Clases completas siempre. Nada de construir nombres con
${}. - Prueba la matriz completa. Es lo que hicimos con los botones: pinta todas las combinaciones en una página.
Errores frecuentes
| Síntoma | Causa | Solución |
|---|---|---|
md:boton no hace nada | Clase en @layer components en v4 | Usa @utility |
Cannot apply unknown utility class | @apply de una clase propia en v4 | Defínela con @utility |
Las utilidades no sobrescriben .boton | CSS fuera de @layer | Mételo en @layer components |
@apply no respeta el orden escrito | Tailwind ordena las declaraciones | No dependas del orden |
class="${base} ${extra}" no cambia nada | El orden del atributo no influye | cn(base, extra) |
| Desaparece un tamaño de letra propio | tailwind-merge cree que text-titulo es un color | extendTailwindMerge |
| Sigue habiendo conflictos con cva | cva no resuelve conflictos | Envuelve con cn() |
| Botón sin color y sin error | Nombre de opción mal escrito | TypeScript o probar la matriz |
tamano: { md: 'lg' } no hace nada en tv | Variantes responsive eliminadas | Escribe md: en las clases |
twMergeConfig da error | Importas de la entrada normal | Importa de tailwind-variants/config |
| Conflictos sin resolver en 3.4 | tailwind-merge 3 sobre v3, o al revés | Usa la rama 2 con Tailwind 3 |
bg-${tono}-50 no pinta nada | Clase construida dinámicamente | Escribe todas las clases completas |
16. Ejercicios
Ejercicio 1: ¿Extraer o no?
Para cada caso, decide qué harías y por qué:
flex items-center justify-betweenaparece en cuarenta sitios.- Una tabla repite doce filas con las mismas ocho clases, y las filas salen de un array.
- Un proyecto HTML puro, sin JavaScript, tiene el mismo botón con quince clases en veinte páginas.
- Las tarjetas de producto y de empleado se parecen, pero el cliente ya ha pedido cambios en una de ellas.
- Un proyecto en React tiene el mismo botón en diez componentes.
Ver solución
- No extraer. Son tres clases muy legibles y no constituyen un concepto de tu aplicación. Un nombre nuevo no aporta nada.
- Un bucle. Están en un fichero y salen de los mismos datos, así que la duplicación desaparece sin crear ninguna abstracción.
- Clase en
@layer components. No hay otra forma de compartirlo. Si además necesitas variantes comomd:, usa@utility. - No unirlas. Se parecen, pero no significan lo mismo, y sus cambios no van a coincidir. Si comparten una parte, extrae solo esa parte.
- Componente con receta. El framework permite reutilizar marcado y comportamiento, que es más que lo que da una clase CSS.
Ejercicio 2: Alerta con @layer components
Escribe .alerta y .alerta-peligro con @apply, de manera que alerta-peligro solo declare lo que cambia, ambas con modo oscuro. Comprueba que class="alerta p-2" reduce el relleno.
Ver solución
@layer components {
.alerta {
@apply rounded-lg border border-sky-300 bg-sky-50 p-4 text-sm text-sky-900;
@apply dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100;
}
.alerta-peligro {
@apply border-red-300 bg-red-50 text-red-900;
@apply dark:border-red-700 dark:bg-red-950 dark:text-red-100;
}
}
class="alerta p-2" funciona porque .p-2 está en la capa utilities, que tiene prioridad sobre components. alerta-peligro vence a alerta porque va después dentro de la misma capa: aquí sí importa el orden del fichero, pero no el del atributo class.
Ejercicio 3: Predice el resultado
Sin ejecutarlo, escribe qué devuelve cada llamada. Partimos de rebajado = true, activo = false y de un cn sin configurar. Compruébalo después.
cn('p-2', 'p-4');
cn('px-2 py-2', 'p-4');
cn('p-4', 'px-2');
cn('text-sm', rebajado && 'text-red-700', activo && 'font-bold');
cn('hover:bg-red-500', 'bg-blue-500');
cn('bg-blue-500', { 'bg-red-500': rebajado });
cn('w-4 h-4', 'size-6');
cn('size-6', 'w-4');
cn('text-titulo', 'text-red-700');
cn('mi-clase', 'otra-clase', 'p-2', 'p-3');
cn('border-2 border-red-500', 'border-blue-500');
cn('truncate', 'whitespace-normal');
Ver solución
Salida real:
p-4
p-4
p-4 px-2
text-sm text-red-700
hover:bg-red-500 bg-blue-500
bg-red-500
size-6
size-6 w-4
text-red-700
mi-clase otra-clase p-3
border-2 border-blue-500
truncate whitespace-normal
Los casos con truco son estos:
size-6sustituye aw-4yh-4, porque cubre las dos. En orden inverso,w-4se conserva: pisa parte desize-6.text-titulodesaparece porque se confunde con un color. Es el fallo que resuelveextendTailwindMerge.border-2yborder-blue-500no chocan: ancho y color son independientes.truncatedeclara varias propiedades, entre ellaswhite-space.tailwind-mergeno considera que choque conwhitespace-normaly conserva las dos. No siempre detecta lo que tú verías como un conflicto.
Ejercicio 4: Aviso de stock con cva
Crea una receta stock con nivel (alto, bajo, agotado) y un booleano compacto. Cuando un aviso sea agotado y compacto a la vez, debe ir en mayúsculas con algo de espaciado entre letras. Escribe nivelDe(n), que traduzca unidades a nivel: 0 es agotado y menos de 5 es bajo.
Ver solución
import { cva } from 'class-variance-authority';
const stock = cva('inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium', {
variants: {
nivel: {
alto: 'bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200',
bajo: 'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200',
agotado: 'bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200',
},
compacto: { true: 'px-1.5 py-0' },
},
compoundVariants: [
{ nivel: 'agotado', compacto: true, class: 'uppercase tracking-wide' },
],
defaultVariants: { nivel: 'alto' },
});
const nivelDe = (n) => (n === 0 ? 'agotado' : n < 5 ? 'bajo' : 'alto');
const html = [25, 3, 0]
.map((n) => `<span class="${stock({ nivel: nivelDe(n) })}">${n} uds.</span>`)
.join('');
La decisión de negocio (cuándo hay poco stock) queda en nivelDe, fuera de la receta. Si cambia el umbral, no tocas los estilos. Ten en cuenta que compacto choca con px-2 py-1 de la base: en este resultado cva deja las dos clases. Para que se resuelva, envuelve la receta con cn().
La primera fila de la figura 21 muestra el resultado.
Ejercicio 5: Alerta con slots
Crea una alerta con tv con slots raiz, marca, titulo y texto. Tendrá tono (info, exito, peligro) y compacta, que reduce el relleno y oculta el texto. El tono peligro cambia también el color del título. Usa role="alert" solo para el tono de peligro.
Ver solución
import { tv } from 'tailwind-variants';
const alerta = tv({
slots: {
raiz: 'flex gap-3 rounded-lg border p-4',
marca: 'mt-0.5 shrink-0 font-bold',
titulo: 'font-semibold',
texto: 'mt-1 text-sm',
},
variants: {
tono: {
info: { raiz: 'border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100' },
exito: { raiz: 'border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100' },
peligro: {
raiz: 'border-red-300 bg-red-50 text-red-900 dark:border-red-700 dark:bg-red-950 dark:text-red-100',
titulo: 'text-red-800 dark:text-red-200',
},
},
compacta: { true: { raiz: 'p-2', texto: 'hidden' } },
},
defaultVariants: { tono: 'info' },
});
const verAlerta = (tono, signo, titulo, texto, rol, opciones = {}) => {
const a = alerta({ tono, ...opciones });
return `<div role="${rol}" class="${a.raiz()}">
<span aria-hidden="true" class="${a.marca()}">${signo}</span>
<div><p class="${a.titulo()}">${titulo}</p><p class="${a.texto()}">${texto}</p></div>
</div>`;
};
verAlerta('peligro', '!', 'Pago rechazado', 'Revisa los datos de la tarjeta.', 'alert');
p-2 sustituye a p-4 sin más trabajo, porque tv resuelve los conflictos. El rol también importa: role="alert" interrumpe al lector de pantalla, algo que debe reservarse para lo urgente, y por eso los demás tonos usan role="status".

Figura 21. Resultados reales de los ejercicios 4, 5, 6 y 7.
Ejercicio 6: Corrige tailwind-merge
Con el token --text-titulo en tu tema, cn('text-titulo font-semibold', 'text-red-700') pierde el tamaño. Corrígelo y compara el antes y el después.
Ver solución
import { extendTailwindMerge } from 'tailwind-merge';
const miTwMerge = extendTailwindMerge({
extend: { theme: { text: ['titulo'] } },
});
miTwMerge('text-titulo font-semibold', 'text-red-700');
// "text-titulo font-semibold text-red-700"
En la figura 21, la primera línea roja usa esta configuración y conserva sus 22 px. La segunda usa cn sin configurar y cae al tamaño normal, sin ningún error.
Ejercicio 7: Paginación
Crea una paginación con tv de dos slots, lista y enlace. La página actual se marcará con aria-current="page", sin ninguna variante en la receta: el estilo debe salir del atributo.
Ver solución
import { tv } from 'tailwind-variants';
const paginacion = tv({
slots: {
lista: 'inline-flex items-center gap-1',
enlace: [
'grid size-10 place-items-center rounded-lg text-sm font-medium tabular-nums',
'text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600',
'aria-[current=page]:bg-indigo-600 aria-[current=page]:text-white',
'aria-[current=page]:hover:bg-indigo-600',
],
},
})();
const html = `
<nav aria-label="Paginación">
<ol class="${paginacion.lista()}">
${[1, 2, 3, 4, 5].map((n) => `
<li>
<a href="#p${n}" class="${paginacion.enlace()}" ${n === 3 ? 'aria-current="page"' : ''}>${n}</a>
</li>`).join('')}
</ol>
</nav>`;
Con tv y slots se llama a la receta una vez, con los paréntesis finales, y se obtienen las funciones. El atributo aria-current hace dos trabajos a la vez: el lector anuncia la página actual y Tailwind aplica el estilo. Así, es imposible que ambos se desincronicen.
Ejercicio 8: De @layer components a @utility
Necesitas que .alerta funcione con md:alerta. Conviértela a utilidad y explica qué cambia si alguien escribe class="alerta p-2".
Ver solución
@utility alerta {
@apply rounded-lg border p-4 text-sm;
}
El CSS generado en 4.3.3 incluye .alerta, .p-2 en ese orden, y .md\:alerta dentro de su @media. Por tanto, class="alerta p-2" sigue dando el relleno pequeño: alerta tiene más propiedades y Tailwind la coloca delante. Lo que antes garantizaba la capa, ahora lo da la regla de ordenación.
Ejercicio 9 (propuesto): Sistema en 3.4
Pasa el sistema de botones, insignias y tarjeta a Tailwind 3.4: crea el proyecto, instala tailwind-merge@2 y tailwind-variants@0.3, añade focus-visible:outline y comprueba los contrastes con la paleta de esa versión.
Ejercicio 10 (propuesto): Campo de formulario
Crea con tv un componente de campo con slots raiz, etiqueta, entrada, ayuda y error, y con la variante booleana invalido. Enlaza etiqueta y entrada con for, y el error con aria-describedby. Lo ampliarás en el módulo 17.
Ejercicio 11 (propuesto): Grupo de botones
Crea un grupo de botones segmentado. Con las variantes de hijos del módulo 11, quita los bordes redondeados internos sin añadir ninguna clase a cada botón, y comprueba que el foco no queda tapado por el botón vecino.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Cuándo extraer | Regla de tres | Si las copias significan lo mismo y cambian a la vez |
| Primer recurso | Bucle o plantilla | Elimina la duplicación sin abstracciones |
| Clase propia | @layer components | Pierde frente a las utilidades. En v4 no admite variantes y no se elimina |
| CSS sin capa | .x { } fuera de @layer | Vence a todas las utilidades |
| Copiar utilidades | @apply | Copia declaraciones en orden propio. Reservar para lo que no admite clases |
Peso de @apply | vite build | Unos 400 bytes de diferencia con gzip: no es lo que debe decidir |
| Utilidad componente | @utility | Admite variantes y @apply. Se ordena por propiedades |
| Conflicto | px-4 px-2 | Decide el orden de la hoja, nunca el del atributo |
| Condiciones | clsx | Une clases e ignora valores falsos |
| Fusión | twMerge | Conoce la gramática de Tailwind y conserva la última |
| Asistente | cn() | twMerge(clsx(...)) |
| Tokens en la fusión | extendTailwindMerge | Obligatorio con tamaños propios en text-* |
| Versión de fusión | tailwind-merge | Rama 3 para Tailwind 4; rama 2 para 3.4 |
| Variantes | cva | Declarativo, de un elemento y sin resolver conflictos |
| Variantes y fusión | tv | Motor propio, slots y extend |
| Partes de un componente | slots, compoundSlots | Una variante escribe en varios elementos |
| Tipos | VariantProps | Saca los tipos de la receta |
| Estados | disabled, aria-* | Salen del HTML, no de la receta |
| Tarjeta clicable | after:absolute after:inset-0 | El enlace sigue en el título y su zona de clic se estira |
| Foco en la tarjeta | has-[a:focus-visible]: | El contorno se dibuja en la raíz |
| Badges | Texto, icono y color | El estado nunca depende solo del color |
| Colores forzados | border border-transparent | El borde invisible reaparece y conserva la forma |
| Mapa de estados | estadosPedido | Centraliza la traducción de dato a interfaz |