Módulo 15: Utilidades y plugins propios
Objetivos del módulo
- Elegir la herramienta adecuada para ampliar Tailwind: valor arbitrario,
@theme,@utility,@custom-variant, CSS propio o plugin - Crear utilidades estáticas con
@utilityy entender qué ganan frente a una clase escrita a mano - Predecir qué utilidad gana cuando la tuya compite con una de Tailwind
- Crear utilidades funcionales con
--value(), conociendo sus cuatro formas: tema, valor desnudo, literal y arbitrario - Añadir modificadores con
--modifier()y valores por defecto con--default() - Crear variantes con
@custom-variant, en su forma corta y con@slot, y saber cuáles se combinan congroup-*,peer-*ynot-* - Aplicar variantes dentro de tu CSS con
@variant, incluidas las apiladas y compuestas de la 4.3 - Usar
@applycon criterio y conocer lo que cambia entre v3 y v4 - Compartir tema y utilidades con CSS Modules y bloques
<style>mediante@reference, sin duplicar CSS - Usar las funciones
--spacing(),--alpha()y--theme() - Cargar plugins con
@plugin, pasarles opciones y escribir los tuyos conplugin()yplugin.withOptions() - Dominar la API de plugins:
addBase,addUtilities,addComponents,matchUtilities,addVariant,matchVariantytheme() - Traer a v4 un
tailwind.config.jscon@configy saber qué opciones ya no se leen - Diagnosticar los errores más frecuentes a partir de su mensaje real
🔄 Cambia en Tailwind 4.0. Casi todo este módulo se escribe en CSS, y en la LTS (Tailwind 3.4)
@utility,@custom-variant,@variant,@plugin,@reference,--spacing(),--alpha()y--theme()no se procesan: pasan tal cual al CSS final, sin error. Si usas la LTS: las utilidades y variantes se escriben en@layer utilitieso con la API de plugins entailwind.config.js. Esta API es casi la misma en las dos versiones, así que las secciones 12 a 14 te sirven enteras. Cada apartado afectado incluye su equivalente concreto.
1. ¿Hace falta crear algo?
Tailwind se puede ampliar de muchas maneras, y el error habitual es elegir la más potente en lugar de la más sencilla. Antes de escribir nada, recorre esta lista de arriba abajo y quédate en la primera respuesta afirmativa.
Figura 1. Cuanto más abajo, más potencia y más código que mantener. La mayoría de los proyectos no pasa de @utility.
Conviene ver por qué el orden importa con un ejemplo. Quieres un color de marca en tus botones:
| Solución | Código | Qué consigues |
|---|---|---|
| Arbitrario | bg-[#6366f1] | Funciona, pero el color queda repetido por todo el HTML |
| Tema | --color-marca-500: #6366f1; | bg-marca-500, text-marca-500, border-marca-500, ring-marca-500… |
| Utilidad | @utility fondo-marca { ... } | Una sola clase, sin las demás. Más código y menos resultado |
Para un valor, @theme gana siempre, y lo viste en el módulo 14. @utility tiene sentido cuando lo que falta no es un valor sino CSS: una propiedad sin clase en Tailwind, o varias declaraciones que siempre van juntas.
Durante todo el módulo seguimos con el proyecto Vite del módulo 01 y la tienda de EMPRESA. Vamos a reunir lo que escribamos en un fichero aparte, utilidades-tienda.css, que se importa después de Tailwind:
/* app.css */
@import "tailwindcss";
@import "./utilidades-tienda.css";
Puedes dejarlo todo en app.css, pero un fichero aparte se reutiliza mejor entre proyectos, como veremos en la sección 15. Los ejemplos usan dos clases de decoración, .caja y .rotulo, que solo dibujan cajas y leyendas para las capturas. Son las mismas ideas del módulo 08 y no forman parte de la técnica.
⚠️ Importante: Vite no detecta siempre los HTML creados con el servidor en marcha. Si una clase nueva no aparece, guarda
app.csssin cambiar nada para que Tailwind vuelva a escanear el proyecto.
2. Utilidades estáticas con @utility 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe
@utility: se queda sin procesar en el CSS final. Si usas la LTS: escribe la clase dentro de@layer utilities { .fondo-rayas { ... } }, que allí sí admite variantes y solo se genera si se usa.
Una utilidad estática es una clase sin parámetros. Vamos con una que Tailwind no trae: un fondo a rayas para marcar los productos agotados.
/* utilidades-tienda.css */
@utility fondo-rayas {
background-image: repeating-linear-gradient(
-45deg,
rgb(100 116 139 / 0.14) 0 8px,
transparent 8px 16px
);
}
<article class="rounded-lg border border-slate-200 p-4">...</article>
<article class="fondo-rayas rounded-lg border border-slate-200 p-4 text-slate-500">...</article>
<article class="rounded-lg border border-slate-200 p-4 hover:fondo-rayas">...</article>

Figura 2. fondo-rayas fijo en la tarjeta central y hover:fondo-rayas en la tercera, capturada con el ratón encima.
Fíjate en lo que has obtenido sin pedirlo:
@layer utilities {
.fondo-rayas {
background-image: repeating-linear-gradient(-45deg, rgb(100 116 139 / 0.14) 0 8px, transparent 8px 16px);
}
@media (hover: hover) {
.hover\:fondo-rayas:hover {
background-image: repeating-linear-gradient(-45deg, rgb(100 116 139 / 0.14) 0 8px, transparent 8px 16px);
}
}
}
Qué gana @utility frente a una clase normal
En v4, escribir la clase a mano y registrarla con @utility son cosas muy distintas:
@layer utilities {
.a-mano { color: red; }
}
@utility registrada { color: red; }
.a-mano en @layer utilities | @utility registrada | |
|---|---|---|
| ¿Se genera si no se usa? | Sí, siempre | No |
¿Funciona hover:, md:…? | No: hover:a-mano no genera nada | Sí |
¿Funciona el sufijo !? | No | Sí |
¿Se puede usar con @apply? | No | Sí |
| ¿Se ordena con el resto? | No, va donde la escribas | Sí |
La diferencia de fondo es que .a-mano es solo CSS que Tailwind deja pasar, mientras que registrada entra en su motor como una utilidad más. Al escanear el HTML, Tailwind busca esa clase, y solo entonces la escribe en el resultado. Es lo mismo que hace con flex o p-4.
⚠️ Importante: Quien viene de v3 suele traer sus utilidades dentro de
@layer utilitiesy se sorprende: en v4 la clase sigue funcionando, perohover:a-manono genera nada y no hay ningún aviso. Si una clase necesita variantes, pásala a@utility.
Selectores anidados dentro de la utilidad
El nombre de una utilidad es siempre una sola clase. Para afectar a pseudoelementos, hijos o consultas de medios, anida dentro del bloque. Este es un ejemplo clásico, ocultar la barra de desplazamiento:
@utility sin-barra {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
<div class="sin-barra flex w-96 gap-2 overflow-x-auto ...">...</div>

Figura 3. Las dos tiras se desplazan igual. La de abajo oculta la barra, aunque pierde la pista visual de que hay más contenido: úsala solo cuando se vea algo cortado que invite a deslizar.
Dentro del bloque vale CSS anidado normal, y las variantes lo respetan. @utility foo { color: red; .bar & { color: blue; } } con hover:foo genera, tal cual:
.hover\:foo:hover { color: red; }
.bar .hover\:foo:hover { color: blue; }
💡 Consejo: En 4.3
sin-barraya tiene una forma nativa,scrollbar-none, que viste en el módulo 07. La utilidad propia sigue siendo un buen ejemplo de anidación, y es lo que necesitas si compilas con v3.
Un grupo de declaraciones
@utility también sirve para agrupar propiedades que no tienen sentido por separado:
@utility truncar-2 {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
Sirve de ejemplo, porque Tailwind trae line-clamp-2, que hace esto mismo. Antes de crear una utilidad, compruébalo en la documentación. Si Tailwind ya resuelve el problema, tu utilidad es deuda.
Reglas del nombre
Estos son los mensajes reales que da Tailwind 4.3.3 cuando el nombre no vale:
| Nombre | Resultado |
|---|---|
foo, foo-bar, foo_bar, -foo | Correcto |
Foo, .foo, foo:bar, @foo, foo-, ñu | `@utility Foo` defines an invalid utility name. Utilities should be alphanumeric and start with a lowercase letter. |
foo-*-bar | `@utility foo-*-bar` defines an invalid utility name. The dynamic portion marked by `-*` must appear once at the end. |
Dentro de @media | `@utility` cannot be nested. |
| Bloque vacío | `@utility foo` is empty. Utilities should include at least one property. |
Fíjate en ñu: las letras con tilde y la ñ no valen en el nombre, igual que no conviene usarlas en una clase. Por eso en este curso escribimos sin-barra y pestana.
Ampliar una utilidad de Tailwind
Si registras una utilidad con el nombre de una existente, las dos se suman. No se sustituye nada:
@utility flex {
gap: 1rem;
}
/* Resultado de class="flex" */
.flex {
display: flex;
gap: 1rem;
}
Ya lo usaste para centrar container en el módulo 07. Con clases muy comunes como flex, en cambio, es una mala idea: quien lea tu HTML espera que flex sea solo display: flex.
3. Quién gana: el orden de tus utilidades 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) las clases de
@layer utilitiesvan detrás de las nativas y ganan siempre: enabeto p-2mandaabeto. En 4.0 se ordenan junto a las nativas, y el resultado depende de sus propiedades y de su nombre. Si usas la LTS: no necesitas hacer nada, tus utilidades ganan a las nativas de igual especificidad.
Un punto que la documentación no detalla, y que conviene medir. Tenemos dos utilidades idénticas, salvo por el nombre:
@utility abeto { padding: 2rem; }
@utility zona { padding: 2rem; }
<div class="abeto p-2">Contenido</div>
<div class="zona p-2">Contenido</div>

Figura 4. Dos utilidades con el mismo CSS y resultado opuesto frente a p-2.
El orden de las clases en el atributo class no influye, como aprendiste en el módulo 02: gana la regla que aparece después en el CSS. Y lo que genera Tailwind con estas clases es esto (compilado con 4.3.3):
@layer utilities {
.zeta-larga { padding: 1rem; color: red; background: white; } /* 3 declaraciones */
.mezcla { color: red; padding: 1rem; } /* 2 declaraciones */
.abeto { padding: 2rem; }
.p-2 { padding: calc(var(--spacing) * 2); }
.zona { padding: 2rem; }
.px-8 { padding-inline: calc(var(--spacing) * 8); }
.text-blue-500 { color: var(--color-blue-500); }
}
De esa salida se deduce la regla práctica:
- Entre utilidades que tocan la misma propiedad, las que declaran más propiedades van antes, así que pierden. Es lo que quieres:
tarjeta p-2deja ajustar el relleno de una utilidad grande. - Con el mismo número de declaraciones, el orden es alfabético.
abetova antes dep-2, yzonadespués.
⚠️ Importante: Una utilidad propia de una sola propiedad puede ganar o perder frente a una nativa según cómo se llame. Si no te conviene depender de eso, no combines las dos clases en un mismo elemento, o usa
p-2!para forzar la decisión. En el módulo 02 tienes el modificador!.
4. Utilidades funcionales con --value() 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe
--value(): las utilidades con parámetro se crean conmatchUtilities()en un plugin. Si usas la LTS: escribematchUtilities({ rejilla: (v) => ({ ... }) }, { values: theme('spacing') })entailwind.config.js, como explica la sección 13.
Una utilidad funcional acepta un valor, como p-4 o bg-red-500. Su nombre termina en -*, y el cuerpo usa --value() para decir qué valores acepta. Esta función no es CSS: Tailwind la resuelve al compilar y no llega al navegador.
@utility rejilla-* {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(--spacing(--value(number)), 100%), 1fr));
}
rejilla-56 crea tantas columnas de al menos 14 rem como quepan, y min(..., 100%) evita que desborde en pantallas estrechas. Es el patrón de catálogo adaptable del módulo 09, convertido en una sola clase.
/* Resultado de class="rejilla-56" */
.rejilla-56 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(calc(var(--spacing) * 56), 100%), 1fr));
}
Las cuatro formas de --value()
| Forma | Sintaxis | Acepta | Ejemplo de clase |
|---|---|---|---|
| Tema | --value(--color-*) | Claves de ese espacio de nombres del tema | franja-rose-500 |
| Valor desnudo | --value(integer) | Un valor sin corchetes del tipo indicado | rejilla-56 |
| Literal | --value("auto", "none") | Exactamente esas palabras | alinea-start |
| Arbitrario | --value([length]) | Valor entre corchetes del tipo indicado | rejilla-[18rem] |
Valores desnudos. Solo hay cuatro tipos, y son más estrictos de lo que parece. Medido en 4.3.3:
| Tipo | Coincide | No coincide |
|---|---|---|
integer | a-2 | a-1.5, a--3 |
number | b-2, b-1.5 | b-1.3 |
percentage | c-50% | c-50 |
ratio | d-3/4 | d-3 |
number solo acepta múltiplos de 0,25. Por eso existe p-1.5 y no p-1.3. Tailwind aplica la misma regla a la escala de espaciado, y tus utilidades la heredan.
Valores del tema. --value(--color-*) busca entre las variables que empiezan por --color-, y lo que coincide con el asterisco es la parte final de la clase. Puedes inventar espacios de nombres propios:
@theme {
--hueco-estrecho: 0.5rem;
--hueco-holgado: 1.5rem;
}
@utility hueco-* {
gap: --value(--hueco-*);
}
/* Resultado de class="hueco-estrecho" */
:root { --hueco-estrecho: 0.5rem; }
.hueco-estrecho { gap: var(--hueco-estrecho); }
La variable del tema, como las demás, solo llega al CSS final si alguna clase la usa. Una clase que no esté en el tema, como hueco-enorme, no genera nada.
Valores literales. Las comillas son obligatorias, y la palabra pasa tal cual al CSS:
@utility alinea-* {
text-align: --value("start", "end");
}
Literal significa eso: no hay traducción. --value("compacta") escribiría padding-block: compacta, que no es CSS válido. Si necesitas convertir palabras en valores, usa un espacio de nombres del tema.
Valores arbitrarios. Los corchetes indican que se aceptan valores entre corchetes, y el tipo limita cuáles:
@utility n-* { x: --value([length]); }
@utility o-* { x: --value([*]); }
| Clase | ¿Genera? | Por qué |
|---|---|---|
n-[2px] | Sí | Es una longitud |
n-[red] | No | No es una longitud |
n-(--z) | No | Una variable no tiene tipo conocido |
n-(length:--z) | Sí, var(--z) | Le dices el tipo a mano |
o-[red] | Sí | * acepta cualquier cosa |
o-(--z) | Sí | * acepta cualquier cosa |
Los tipos arbitrarios disponibles, según la documentación oficial, son absolute-size, angle, bg-size, color, family-name, generic-name, image, integer, length, line-width, number, percentage, position, ratio, relative-size, url, vector y *.
Combinar las formas
Si escribes varias formas dentro de un --value(), se prueban de izquierda a derecha:
@utility sangria-* {
text-indent: --value(--sangria-*, [length], [percentage]);
}
Cuando cada caso necesita un tratamiento distinto, repite la declaración. Tailwind descarta las que no se pueden resolver, de modo que solo queda una:
@utility rejilla-* {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(--spacing(--value(number)), 100%), 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(--value([length]), 100%), 1fr));
}
<div class="rejilla-32 gap-2">...</div>
<div class="rejilla-56 gap-2">...</div>
<div class="rejilla-[18rem] gap-2">...</div>

Figura 5. Con un ancho de 852 px, cambiar un número decide cuántas columnas caben, sin ningún punto de ruptura.
El CSS resultante solo lleva la línea que corresponde:
.rejilla-56 { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(calc(var(--spacing) * 56), 100%), 1fr)); }
.rejilla-\[18rem\] { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr)); }
Dónde puede aparecer --value()
En cualquier valor de una declaración, también en propiedades personalizadas y dentro de bloques anidados:
@utility pila-* {
& > * + * {
margin-top: --spacing(--value(integer));
}
}
/* Resultado de class="pila-2" */
.pila-2 > * + * {
margin-top: calc(var(--spacing) * 2);
}
Si --value() aparece dos veces en la misma declaración, las dos tienen que resolverse. Si una falla, se descarta la declaración entera:
@utility t-* { padding: --value(integer) --value(integer); }
/* Resultado de class="t-2" */
.t-2 { padding: 2 2; }
Valores negativos
Un asterisco no captura el signo menos. La clase con guion delante es otra utilidad, y hay que registrarla aparte:
@utility desplaza-* {
translate: --spacing(--value(integer));
}
@utility -desplaza-* {
translate: --spacing(--value(integer) * -1);
}
.-desplaza-2 { translate: calc(var(--spacing) * 2 * -1); }
.desplaza-2 { translate: calc(var(--spacing) * 2); }
Fracciones
El tipo ratio indica a Tailwind que la barra forma parte del valor, en lugar de separar un modificador:
@utility prop-* {
aspect-ratio: --value(--aspect-*, ratio, [ratio]);
}
.prop-3\/4 { aspect-ratio: 3 / 4; }
.prop-\[7\/9\] { aspect-ratio: 7/9; }
.prop-video { aspect-ratio: var(--aspect-video); }
Errores frecuentes con --value()
Olvidar el asterisco. Si el nombre no termina en -*, --value() no se resuelve, y llega tal cual al navegador. No hay error de compilación:
@utility foo { color: --value(--color-*); }
.foo { color: --value(--color-*); }
Olvidar --value(). Es el caso contrario, y aquí hay un cambio entre versiones.
--value() obligatorio y varias definiciones 🔄 v4.3
🔄 Cambia en Tailwind 4.3. En versiones anteriores,
@utility foo-* { color: red; }generaba CSS para cualquier sufijo, y dos@utilitycon el mismo nombre y distinto tipo no se complementaban. En la LTS (Tailwind 3.4)@utilityno existe. Si usas la LTS: llama amatchUtilities()una vez por grupo de valores, con sutype.
Medido con las versiones históricas, @utility foo-* { color: red; } en 4.2 genera .foo-1 y .foo-bar, sin validar nada. En 4.3 no genera ninguna de las dos, sin aviso. Si tu clase ha dejado de aparecer tras actualizar, revisa que el cuerpo use --value().
La otra novedad permite repartir tipos entre varias definiciones con el mismo nombre:
@utility h-x-* { color: --value(--color-*); }
@utility h-x-* { font-size: --value([length]); }
En 4.3 h-x-red-500 da color y h-x-[2px] da font-size. En 4.2 solo funciona la primera.
5. Modificadores con --modifier() 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe
--modifier(). Si usas la LTS: la función dematchUtilities()recibe{ modifier }como segundo argumento, y declaras los valores válidos con la opciónmodifiers. Contype: 'color', la opacidad/50funciona sola.
El modificador es lo que va detrás de la barra, como el /50 de bg-red-500/50. --modifier() funciona igual que --value(), pero lee ese fragmento.
Figura 6. Cada parte de la clase llega a una función distinta.
Veamos primero la versión ingenua de una franja lateral de color:
@utility franja-* {
border-inline-start: 0.25rem solid --value(--color-*, [color]);
}
franja-rose-500 funciona, pero franja-rose-500/50 no genera nada. Tailwind no aplica opacidad por su cuenta en una utilidad @utility. Si el cuerpo no usa --modifier(), la clase con barra no es válida. Lo mismo ocurre si el modificador no coincide con ninguna forma de --modifier(), como verás enseguida con cuerpo-sm/raro.
Para que la barra funcione tienes que usarla tú, y --alpha() hace el cálculo:
@utility franja-* {
border-inline-start: 0.25rem solid --alpha(--value(--color-*, [color]) / calc(--modifier(integer, --default(100)) * 1%));
}
<div class="franja-marca-500 ...">Color propio del tema</div>
<div class="franja-rose-500 ...">Color de la paleta</div>
<div class="franja-rose-500/40 ...">Con modificador de opacidad</div>
<div class="franja-[#0d9488] ...">Color arbitrario</div>

Figura 7. La misma utilidad con un color del tema, de la paleta, con opacidad y arbitrario.
--default(100) hace que franja-rose-500 tenga opacidad completa, y se explica en la sección siguiente. --alpha() se ve con más detalle en la sección 11.
El modificador no tiene por qué ser una opacidad
La barra no significa nada por sí misma: el significado se lo das tú. Las fuentes de Tailwind usan text-sm/6 para fijar la altura de línea. Puedes repetir esa idea:
@theme {
--leading-holgado: 1.8;
}
@utility cuerpo-* {
font-size: --value(--text-*, [length]);
line-height: --modifier(--leading-*, [length], [*]);
}
.cuerpo-sm { font-size: var(--text-sm); }
.cuerpo-sm\/holgado { font-size: var(--text-sm); line-height: var(--leading-holgado); }
.cuerpo-sm\/\[2rem\] { font-size: var(--text-sm); line-height: 2rem; }
Sin barra, la declaración que depende de --modifier() desaparece, y queda solo font-size. cuerpo-sm/raro no genera nada, porque raro no está en el tema ni va entre corchetes.
6. Valores por defecto con --default() 🆕 v4.3
🆕 Desde Tailwind 4.3. No existe en la LTS (Tailwind 3.4). Si usas la LTS: en
matchUtilities()añadeDEFAULTavaluespara el valor sin sufijo, comovalues: { DEFAULT: theme('spacing.1'), 2: theme('spacing.2') }, y para el modificador usamodifier ?? 'currentColor'en la función.
--default() va dentro de --value() o de --modifier(), y da un valor para cuando la clase no lo trae. Dentro de --value(), además, permite usar la utilidad sin sufijo:
@utility pestana-* {
tab-size: --value(integer, --default(4));
}
.pestana { tab-size: 4; }
.pestana-2 { tab-size: 2; }
Con un --modifier(), conserva la declaración cuando no hay barra:
@utility pestana-* {
tab-size: --value(integer);
line-height: --modifier(integer, --default(1));
}
.pestana-2 { tab-size: 2; line-height: 1; }
.pestana-2\/3 { tab-size: 2; line-height: 3; }
Reunimos todo en una utilidad completa, una sombra dura al estilo cómic, con grosor y color opcionales:
@utility sombra-dura-* {
--desplazamiento: --spacing(--value(integer, --default(1)));
box-shadow: var(--desplazamiento) var(--desplazamiento) 0 --modifier(--color-*, --default(currentColor));
}
<div class="sombra-dura ...">Tarjeta</div>
<div class="sombra-dura-2 ...">Tarjeta</div>
<div class="sombra-dura-2/rose-500 ...">Tarjeta</div>
<div class="sombra-dura-3/indigo-500 ...">Tarjeta</div>

Figura 8. Aquí el modificador es un color. currentColor toma el color del texto de la tarjeta.
.sombra-dura {
--desplazamiento: calc(var(--spacing) * 1);
box-shadow: var(--desplazamiento) var(--desplazamiento) 0 currentColor;
}
.sombra-dura-2\/rose-500 {
--desplazamiento: calc(var(--spacing) * 2);
box-shadow: var(--desplazamiento) var(--desplazamiento) 0 var(--color-rose-500);
}
La propiedad personalizada evita repetir --value() y deja el resultado legible. Observa que el cálculo de --spacing() sigue dependiendo del tema, de modo que cambiar --spacing en el módulo 14 escala también tus utilidades.
⚠️ Importante: En 4.3.3
--default()pierde los espacios de su argumento.--default(0 0 2px red)compila a002pxred, un valor no válido y sin aviso. El registro de cambios del proyecto recoge el arreglo como pendiente de publicar. Hasta entonces, guarda el valor en el tema y usa--default(var(--sombra-base)), que sí funciona.
7. Variantes propias con @custom-variant 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) no existe
@custom-variant: queda sin procesar y la claseenviado:flexno genera nada. Si usas la LTS: registra la variante en un plugin conaddVariant('enviado', '&[data-estado="enviado"]'). En 3.4 se admiten selectores, un@mediay un array con varios.
Ya usaste @custom-variant en el módulo 12 para dark y en el módulo 10 para una consulta por altura. Ahora la vemos entera. Una variante responde a la pregunta cuándo se aplica una utilidad.
Forma corta
@custom-variant enviado (&[data-estado="enviado"]);
@custom-variant cancelado (&[data-estado="cancelado"]);
El & representa la clase que se está generando. Con eso, data-[estado=enviado]:bg-sky-50, que tendrías que repetir por todo el HTML, se queda en enviado:bg-sky-50:
<li data-estado="enviado"
class="group flex ... enviado:border-sky-400 enviado:bg-sky-50 cancelado:bg-slate-100 cancelado:opacity-70">
<span class="group-cancelado:line-through">Pedido 5021 · Construcciones Levante S.A.</span>
<span class="hidden ... group-enviado:inline">En camino</span>
<span>649,00 €</span>
</li>

Figura 9. El HTML solo cambia el atributo data-estado. Todo lo demás lo deciden las variantes.
Aunque estén definidas por ti, las variantes de selector se combinan con el resto de Tailwind:
.not-enviado\:flex:not([data-estado="enviado"]) { display: flex; }
.group-enviado\:flex:is(:where(.group)[data-estado="enviado"] *) { display: flex; }
.peer-enviado\:flex:is(:where(.peer)[data-estado="enviado"] ~ *) { display: flex; }
.has-enviado\:flex:has([data-estado="enviado"]) { display: flex; }
:where([data-estado="enviado"]) .in-enviado\:flex { display: flex; }
Tu variante también se apila con cualquier otra, como md:enviado:flex o enviado:hover:flex.
Variantes que miran a un ancestro
Es la forma de crear temas o campañas. Solo cambia el selector:
@custom-variant rebajas (&:where([data-campana="rebajas"] *));
<div data-campana="rebajas">
<p>
<span class="rebajas:text-slate-400 rebajas:line-through">329 €</span>
<span class="hidden font-bold text-rose-600 rebajas:inline">249 €</span>
</p>
<button class="caja bg-indigo-600 rebajas:bg-rose-600">Comprar</button>
</div>

Figura 10. Mismo HTML. Basta poner el atributo en <body> para activar la campaña en toda la tienda.
Usa :where(), como en el módulo 12, para que la variante no sume especificidad. Sin él, rebajas:bg-rose-600 ganaría siempre frente a otras utilidades.
Consultas de medios y otras reglas @
@custom-variant apaisado (@media (orientation: landscape));
Funciona con not-*, que Tailwind invierte (@media not (...)), pero no con group-*, porque group-apaisado:flex no genera nada. Tiene su lógica: una consulta de medios no pregunta por un elemento, así que no tiene sentido preguntar por el grupo.
Forma con cuerpo y @slot
Cuando necesitas anidar o varias reglas, usa un bloque. @slot indica dónde va la utilidad:
@custom-variant activo {
&:hover {
@slot;
}
&:focus-visible {
@slot;
}
}
/* Resultado de activo:flex */
.activo\:flex:hover { display: flex; }
.activo\:flex:focus-visible { display: flex; }
Y para reglas anidadas:
@custom-variant raton {
@media (any-hover: hover) {
&:hover {
@slot;
}
}
}
La forma corta también admite varios selectores, (&:hover, &:focus-visible), y genera una regla con coma. Puedes usar otras variantes dentro del cuerpo con @variant, que veremos enseguida:
@custom-variant y {
@variant hover {
@variant md {
@slot;
}
}
}
Sustituir una variante existente
Definir un nombre que ya existe la reemplaza. Lo hiciste con dark y en el módulo 11 para que hover no exija un dispositivo con ratón:
@custom-variant hover (&:hover);
Si defines dos veces el mismo nombre propio, gana la última definición.
Dónde colocan tus variantes en el CSS
Tus variantes se generan detrás de todas las de Tailwind, en el orden en que las escribiste. Lo muestra esta salida:
@media (hover: hover) { .hover\:flex:hover { display: flex; } }
@media (width >= 48rem) { .md\:flex { display: flex; } }
.zzz\:block.z { display: block; }
.aaa\:flex.a { display: flex; }
Por eso activo:bg-slate-50 gana a hover:bg-white y a md:bg-white si coinciden, a igualdad de especificidad. Y si tus propias variantes pueden chocar, define primero la que deba perder.
Errores frecuentes con @custom-variant
| Código | Qué ocurre |
|---|---|
@custom-variant x-* (...) | Error: `@custom-variant x-*` defines an invalid variant name. Variants should only contain alphanumeric, dashes, or underscore characters and start with a lowercase letter or number. No hay variantes funcionales en CSS: usa matchVariant() (sección 14) |
@custom-variant x (&:hover) { ... } | Error: `@custom-variant x` cannot have both a selector and a body. |
Dentro de @media | Error: `@custom-variant` cannot be nested. |
@custom-variant x (.foo); | Compila, y genera .x\:flex .foo: sin & afecta a un descendiente, no al elemento |
Cuerpo sin @slot | Compila, pero tira la utilidad: x:flex genera solo las declaraciones del cuerpo de la variante, sin display: flex |
8. Aplicar variantes en tu CSS con @variant 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4):
@variantllega tal cual al navegador. Si usas la LTS: escribe las variantes dentro de@apply, como@apply hover:bg-indigo-100 dark:bg-slate-800, que genera las mismas reglas; para declaraciones sueltas escribe el selector o la consulta de medios a mano.
@custom-variant crea una variante. @variant usa una que ya existe, dentro de tu propio CSS. Es útil cuando no puedes poner clases en el HTML, como en un componente de terceros. El módulo 12 lo introdujo con dark.
.calendario {
background-color: white;
@variant dark {
background-color: var(--color-slate-800);
}
}
.calendario {
background-color: white;
@media (prefers-color-scheme: dark) {
background-color: var(--color-slate-800);
}
}
La ventaja frente a escribir la consulta a mano es que sigue la configuración del proyecto. Si cambias dark con @custom-variant, todo el CSS con @variant dark lo acompaña, sin tocarlo. Vale igual para los puntos de ruptura y para tus variantes propias.
Admite variantes con parámetro, como @variant data-activo, @variant group-hover, @variant not-hover o @variant [&>li]. Se puede usar en el nivel superior, anidado en otra regla y dentro de @utility:
@variant dark {
.aviso { color: white; }
}
@utility u {
color: red;
@variant dark {
color: blue;
}
}
Una variante desconocida sí da error de compilación: Cannot use `@variant` with unknown variant: nada.
Variantes apiladas y compuestas 🆕 v4.3
🆕 Desde Tailwind 4.3. No existe en la LTS (Tailwind 3.4). Si usas la LTS: apila dentro de
@apply, como@apply hover:focus:bg-red-500, o escribe un selector con coma para la unión. En 4.0 a 4.2, anida un@variantdentro de otro para apilar y repite el bloque para la unión.
Hasta la 4.2, esto fallaba:
Cannot use `@variant` with unknown variant: hover:focus
Cannot use `@variant` with unknown variant: hover, focus
Desde 4.3 hay dos sintaxis nuevas. Los dos puntos significan “y”, como en las clases, y la coma “o”:
.dia {
@variant hover:focus {
color: pink; /* con hover Y con focus */
}
@variant hover, focus {
color: gold; /* con hover O con focus */
}
@variant md:hover, lg:focus {
color: plum; /* se pueden combinar */
}
}
.dia {
&:hover {
@media (hover: hover) {
&:focus {
color: pink;
}
}
}
&:hover {
@media (hover: hover) {
color: gold;
}
}
&:focus {
color: gold;
}
/* ... */
}
La forma con coma es la que más ahorra, sobre todo con hover y focus-visible, que casi siempre van juntos.
9. @apply 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4)
@applyrespeta el orden escrito y acepta cualquier clase, también las de tu CSS. En 4.0 Tailwind ordena las clases por su cuenta y solo admite utilidades registradas: nativas,@utilityo de plugins. Si usas la LTS: tu@applyfunciona igual, pero al migrar pasa a@utilitylas clases que apliques.
@apply copia las declaraciones de unas utilidades dentro de tu regla. Da acceso a tus tokens sin escribir var() a mano, con la misma sintaxis de las clases:
.boton {
@apply rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-700 md:px-6;
}
.boton {
border-radius: var(--radius-lg);
background-color: var(--color-indigo-600);
padding-inline: calc(var(--spacing) * 4);
padding-block: calc(var(--spacing) * 2);
font-weight: var(--font-weight-medium);
color: var(--color-white);
&:hover {
@media (hover: hover) {
background-color: var(--color-indigo-700);
}
}
@media (width >= 48rem) {
padding-inline: calc(var(--spacing) * 6);
}
}
💡 Consejo: Si estás usando
@applypara limpiar tu HTML, detente. Pierdes lo que hace útil a Tailwind, como explicó el módulo 00. Una clase larga que se repite se resuelve con un componente de tu framework, lo que veremos en el módulo 16.@applyestá para los casos en que no controlas el HTML.
Cuándo sí: HTML que no controlas
El caso más claro es el contenido de un gestor de contenidos (CMS), que llega ya escrito y sin clases:
.contenido-cms {
@apply max-w-prose text-slate-700;
& h2 {
@apply mb-2 text-xl font-bold text-slate-900;
}
& p {
@apply mb-3 leading-relaxed;
}
& a {
@apply font-medium text-indigo-700 underline decoration-indigo-300 underline-offset-2;
}
& ul {
@apply mb-3 list-disc ps-5;
}
}

Figura 11. Preflight deja el HTML sin formato. Estos estilos se lo devuelven usando tus tokens. Si el contenido es largo, valora @tailwindcss/typography, del módulo 04.
El segundo caso son los componentes de terceros. Sus nombres de clase están en un HTML que genera una librería, de modo que no puedes añadir utilidades. Combinando @apply y @variant:
.calendario {
@apply w-64 rounded-xl border border-slate-200 bg-white p-3 text-sm text-slate-700 shadow-md;
@variant dark {
@apply border-slate-700 bg-slate-800 text-slate-100;
}
}
.calendario__cabecera {
@apply mb-2 text-center font-semibold;
}
.calendario__dias {
@apply grid grid-cols-7 gap-1;
}
.calendario__dia {
@apply rounded-md p-1 text-center;
@variant hover, focus-visible {
@apply bg-indigo-100 text-indigo-900;
}
&[aria-selected="true"] {
@apply bg-indigo-600 font-bold text-white;
}
&[aria-disabled="true"] {
@apply opacity-40;
}
}

Figura 12. El calendario en modo claro.

Figura 13. El mismo calendario con el sistema en modo oscuro. El HTML es de la librería y no se ha tocado.
Si compilas con 3.4, .calendario__dia se escribe solo con @apply, porque @variant no existe:
.calendario__dia {
@apply rounded-md p-1 text-center hover:bg-indigo-100 focus-visible:bg-indigo-100;
}
El orden dentro de @apply no es el que escribes
Es una trampa nueva de v4, y el motivo es el mismo orden por valor del módulo 02. Compilamos dos reglas, con las mismas clases en orden distinto:
.a { @apply mt-10 mt-2; }
.b { @apply mt-2 mt-10; }
| Regla | Resultado en 3.4.19 | Resultado en 4.3.3 |
|---|---|---|
.a | margin-top: 0.5rem (gana la última escrita) | mt-2 y después mt-10: gana 2,5 rem |
.b | margin-top: 2.5rem | mt-2 y después mt-10: gana 2,5 rem |
En v4 las dos reglas salen iguales: Tailwind ordena las utilidades como lo haría en su hoja, y no mira cómo las has escrito. Además, deja ambas declaraciones. No escribas dos clases que se pisen dentro de un @apply.
Dentro de la regla, en cambio, el lugar del @apply sí cuenta. Se comporta como si pegaras las declaraciones allí mismo:
.a {
color: red;
@apply text-blue-500;
color: green; /* gana: es la última */
}
Solo utilidades registradas
@layer components {
.boton { padding: 0.5rem 1rem; }
}
.boton-primario {
@apply boton bg-indigo-600 text-white;
}
En 3.4 funciona. En 4.3.3 da este error:
Cannot apply unknown utility class `boton`
La solución es registrar la clase con @utility:
@utility boton {
padding: 0.5rem 1rem;
}
Las utilidades pueden aplicarse entre sí, incluidas las funcionales y con variantes:
@utility boton {
@apply rounded px-4 py-2 font-medium;
}
@utility boton-primario {
@apply boton bg-indigo-600 text-white;
}
hover:boton-primario genera las seis declaraciones. Tailwind detecta los ciclos: si a aplica b y b aplica a, el error es You cannot `@apply` the `b` utility here because it creates a circular dependency.
El sufijo ! también funciona: @apply font-bold! escribe font-weight: var(--font-weight-bold) !important.
10. @reference: @apply fuera de tu hoja principal 🆕 v4.0
🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4), donde queda sin procesar. Si usas la LTS: no hace falta directiva: cada CSS lee
tailwind.config.js, y@applyfunciona en CSS Modules y bloques<style>con el tema y los plugins. Las clases de@layerde otra hoja no se ven: pásalas a un plugin para compartirlas.
Tailwind compila cada fichero CSS por separado. Tu app.css sabe qué es bg-red-500 porque tiene @import "tailwindcss". Pero un CSS Module, o un bloque <style> de Vue o Svelte, es un fichero aparte que no sabe nada:
/* ficha.module.css */
.ficha {
@apply bg-red-500;
}
Cannot apply unknown utility class `bg-red-500`. Are you using CSS modules or similar and missing `@reference`?
El mensaje apunta a la solución. La tentación es poner @import "./app.css", y funciona, pero duplica la hoja. @reference carga tema, utilidades y variantes para consultarlos, sin escribirlos en el resultado:
/* ficha.module.css */
@reference "./app.css";
.ficha {
@apply rounded-lg border border-slate-200 p-4;
@variant dark {
@apply border-slate-700 bg-slate-900;
}
}
.titulo {
@apply font-semibold text-marca-700 enviado:underline;
}
Figura 14. Tamaños reales: construimos el proyecto del módulo dos veces con vite build, cambiando solo esa línea. La diferencia es exactamente una copia de la hoja.
Con @reference el módulo ve todo lo que hay en app.css: text-marca-700 viene de @theme, y enviado: de @custom-variant, aunque estén en otro fichero importado. Si no has personalizado nada, puedes referenciar directamente a Tailwind:
@reference "tailwindcss";
Leer un token: var(), --theme() y @apply
Compara lo que producen tres formas de leer un color del tema bajo @reference:
@reference "./app.css";
.a { color: var(--color-marca-900); }
.b { color: --theme(--color-marca-800); }
.c { @apply text-marca-700; }
.a { color: var(--color-marca-900); } /* sin respaldo */
.b { color: var(--color-marca-800, #3730a3); } /* con respaldo */
.c { color: var(--color-marca-700, #4338ca); } /* con respaldo */
Recuerda que Tailwind solo emite las variables del tema que se usan (módulo 14). Las dos últimas reglas no dependen de eso: si la variable falta, se usa el respaldo. .a sí depende. En nuestra prueba con vite build, ningún elemento usaba --color-marca-900 y aun así apareció definida en el CSS final, porque Tailwind encontró su nombre al procesar el proyecto. --color-marca-800, en cambio, no se definió, y .b funciona gracias al respaldo.
La recomendación es sencilla: en ficheros con @reference, prefiere --theme() a var(). Funciona siempre, sin depender de lo que Tailwind detecte en el resto del proyecto.
💡 Consejo: La ruta de
@referencees relativa al fichero. En proyectos con muchas carpetas, cuando../../../app.cssse vuelve incómodo, la documentación oficial recomienda los subpath imports depackage.json, que crean un alias como@reference "#app.css". El módulo 18 aplica@referenceen React, Vue y Angular.
11. Funciones de Tailwind en el CSS 🆕 v4.0
🆕 Desde Tailwind 4.0.
--spacing(),--alpha()y--theme()no existen en la LTS (Tailwind 3.4), donde llegan tal cual al navegador. Si usas la LTS: usatheme(spacing.4),theme(colors.red.500 / 50%)ytheme(colors.red.500), que dan el valor directo.
Son funciones que Tailwind resuelve al compilar. Funcionan en cualquier CSS que procese, tanto en reglas normales como dentro de @utility. Esta es su salida real:
.f {
margin: --spacing(4);
padding: --spacing(0.5);
color: --alpha(var(--color-red-500) / 50%);
background: --theme(--color-red-500);
border-color: --theme(--color-red-500 inline);
width: theme(spacing.12);
}
.f {
margin: calc(var(--spacing) * 4);
padding: calc(var(--spacing) * 0.5);
color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 50%, transparent);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-red-500) 50%, transparent);
}
background: var(--color-red-500);
border-color: oklch(63.7% 0.237 25.331);
width: 3rem;
}
| Función | Qué devuelve | Cuándo usarla |
|---|---|---|
--spacing(n) | calc(var(--spacing) * n) | Longitudes en la escala del proyecto |
--alpha(color / n%) | color-mix() con transparente, con respaldo | Opacidad sobre un color en una regla o @utility |
--theme(--variable) | var(--variable) | Leer un token, también en sitios donde var() no vale |
--theme(--variable inline) | El valor directo | Si necesitas el valor, no la variable |
theme(ruta.con.puntos) | El valor directo | Solo compatibilidad con v3: está obsoleta |
--theme() es especialmente útil en lugares donde el navegador no acepta variables, como las consultas de medios. Tailwind inserta allí el valor, y admite un valor alternativo:
@media (width >= --theme(--breakpoint-md)) {
.f { color: red; }
}
.g { color: --theme(--color-nada, red); }
@media (width >= 48rem) {
.f { color: red; }
}
.g { color: red; }
⚠️ Importante:
--alpha()necesitavar()si le pasas una variable.--alpha(--color-red-500 / 50%)compila sin error acolor-mix(in oklab, --color-red-500 50%, transparent), que no es CSS válido y el navegador descarta. Dentro de@utilityes distinto:--value()ya devuelve elvar(), por eso--alpha(--value(--color-*) / 50%)es correcto.
12. Plugins: @plugin y plugin() 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4)
@pluginqueda sin procesar: los plugins se registran entailwind.config.js, y las opciones se pasan llamando a la función. Si usas la LTS: escribeplugins: [require('./plugins/tienda')({ radio: '0.375rem' })]. El cuerpo del plugin no cambia, y la función se importa conconst plugin = require('tailwindcss/plugin').
La documentación oficial agrupa los plugins de JavaScript en su sección de compatibilidad. No están obsoletos, ya que los oficiales @tailwindcss/typography y @tailwindcss/forms son plugins, pero la dirección del proyecto es clara: lo que puedas hacer en CSS, hazlo en CSS.
Un plugin está justificado cuando necesitas lo que CSS no tiene:
| Necesidad | Por qué pide JavaScript |
|---|---|
| Generar clases con un bucle, a partir de una lista | @utility no tiene bucles |
Calcular un valor con lógica (if, conversiones, contrastes) | --value() solo sustituye |
Variantes con parámetro (pedido-enviado:, pedido-*:) | @custom-variant no admite * |
| Publicar un paquete npm configurable | Las opciones de @plugin llegan a una función |
| Compartir código con 3.4 | La API es la misma en las dos versiones |
Cargar un plugin
@plugin acepta el nombre de un paquete o una ruta relativa al CSS. Tiene que estar en el nivel superior, porque dentro de @media da `@plugin` cannot be nested.:
@plugin "@tailwindcss/typography";
@plugin "./plugins/tienda.js";
Funciona con módulos ES, CommonJS y TypeScript (comprobado con .js, .cjs y .ts), y plugins/mi.ts se transpila solo. Usa import en un proyecto con "type": "module", que es lo que crea Vite. Sin ese campo, un .js con import se carga igualmente, pero Node imprime este aviso: Failed to load the ES module... Make sure to set "type": "module".
Un plugin mínimo
Un plugin es una función que recibe la API. No hace falta nada más:
// plugins/minimo.js
export default function ({ addUtilities }) {
addUtilities({ '.texto-vertical': { writingMode: 'vertical-rl' } });
}
Con plugin() obtienes tipos, y es la forma que verás en la práctica:
import plugin from 'tailwindcss/plugin';
export default plugin(function ({ addUtilities }) {
addUtilities({ '.texto-vertical': { writingMode: 'vertical-rl' } });
});
Los objetos son CSS escrito en JavaScript: propiedades en camelCase, anidación con & y consultas de medios como claves. Tailwind ejecuta el plugin una vez al compilar, en Node. Nada de este código llega al navegador, así que no hay document ni window, pero sí puedes leer ficheros.
Las piezas de la API
Figura 15. Lo que se añade con addBase se escribe siempre. Lo demás solo si se usa la clase.
| Función | Crea | Equivalente en CSS |
|---|---|---|
addBase(css) | Estilos para elementos, en la capa base | CSS en @layer base |
addUtilities(css) | Utilidades estáticas | @utility |
addComponents(css) | Como addUtilities en v4 | @utility |
matchUtilities(fn, opciones) | Utilidades con valor | @utility x-* con --value() |
matchComponents(fn, opciones) | Como matchUtilities en v4 | @utility x-* |
addVariant(nombre, selector) | Variante | @custom-variant |
matchVariant(nombre, fn, opciones) | Variante con parámetro | No tiene |
theme(ruta, porDefecto) | Lee un valor del tema | --theme() |
config(ruta, porDefecto) | Lee la configuración | No tiene |
prefix(selector) | Aplica el prefijo configurado | No tiene |
addComponents en v4 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4)
addComponentsescribe en la capacomponents, y cualquier utilidad lo sobrescribe. En 4.0 escribe enutilities, ordenado como una utilidad más. Si usas la LTS: nada cambia, el plugin funciona igual; al migrar, comprueba los casos en que combines el componente con una utilidad.
.boton y .px-8, en v4:
@layer utilities {
.boton { padding: .5rem 1rem; border-radius: 0.5rem; /* ... */ }
.px-8 { padding-inline: calc(var(--spacing) * 8); }
}
Aquí px-8 sigue ganando porque .boton tiene más propiedades y va antes. Es la regla de la sección 3, y también significa que un componente de una sola propiedad queda a merced de su nombre.
Cómo se reparte el selector
Los objetos admiten selectores complejos, y Tailwind registra cada clase que aparece:
addComponents({ '.tarjeta .titulo': { fontWeight: '700' } });
addUtilities({ '.a-la, .b-la': { color: 'red' } });
Con tarjeta en el HTML ya se genera .tarjeta .titulo, y también si solo aparece titulo. Las clases a-la y b-la funcionan por separado.
13. Utilidades con parámetro: matchUtilities()
matchUtilities recibe un objeto con nombre de utilidad → función, y unas opciones:
matchUtilities(
{
desplaza: (valor) => ({ translate: valor }),
},
{
values: theme('spacing'), // qué sufijos acepta
supportsNegativeValues: true, // genera también -desplaza-*
},
);
.-desplaza-4 { translate: calc(1rem * -1); }
.desplaza-3\.5 { translate: 0.875rem; }
.desplaza-4 { translate: 1rem; }
.desplaza-\[3px\] { translate: 3px; }
| Opción | Para qué sirve |
|---|---|
values | Objeto sufijo → valor. Con clave DEFAULT, la utilidad funciona sin sufijo |
type | Tipo aceptado entre corchetes: 'color', 'length', 'percentage'… o un array |
supportsNegativeValues | Genera la versión con guion |
modifiers | 'any' u objeto con los modificadores válidos |
Los valores entre corchetes funcionan sin hacer nada más, y puedes acotarlos con type.
Colores
Para listar los colores se usa flattenColorPalette, que aplana { red: { 500: ... } } en { 'red-500': ... }:
import plugin from 'tailwindcss/plugin';
import flattenColorPalette from 'tailwindcss/lib/util/flattenColorPalette';
export default plugin(({ matchUtilities, theme }) => {
matchUtilities(
{ franja: (valor) => ({ borderLeftColor: valor }) },
{ values: flattenColorPalette(theme('colors')), type: 'color' },
);
});
Con type: 'color' la opacidad funciona sola, sin definir modificadores:
.franja-red-500\/50 {
border-left-color: color-mix(in oklab, oklch(63.7% 0.237 25.331) 50%, transparent);
}
.franja-\[\#f00\]\/30 {
border-left-color: color-mix(in oklab, #f00 30%, transparent);
}
En 3.4 el mismo código da rgb(239 68 68 / 0.5). Además, theme('colors') incluye los colores de tu @theme: franja-marca-500 funciona.
⚠️ Importante: Si en una utilidad de color añades
modifiers: 'any', Tailwind lo interpreta distinto:franja-red-500/50compila acolor-mix(in oklab, ... 5000%, transparent), un valor que no tiene sentido. Declaramodifierssolo cuando el modificador no sea una opacidad, y entonces léelo en el segundo argumento:(valor, { modifier }) => ....
theme() en un plugin: valores, no variables 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4)
theme('fontSize.2xl')devuelve solo el tamaño. En 4.0 devuelve el par[tamaño, { lineHeight }], y usarlo tal cual en una propiedad genera CSS roto. Si usas la LTS: escribetheme('fontSize.2xl.0'), que devuelve1.5remen las dos versiones.
Este plugin imprime lo que recibe, con @theme { --color-marca-500: #e11d48; }:
theme('colors.red.500') // "oklch(63.7% 0.237 25.331)"
theme('colors.marca.500') // "#e11d48"
theme('spacing.4') // "1rem"
theme('screens.md') // "48rem"
theme('borderRadius.lg') // "0.5rem"
theme('nada.x', 'porDefecto') // "porDefecto"
theme('fontSize.2xl') // ["1.5rem", { lineHeight: "calc(2 / 1.5)" }]
theme('fontSize.2xl.0') // "1.5rem"
Lo importante es que recibes valores, no var(--color-red-500). Tiene consecuencias:
- Lo que genere el plugin no reacciona a un cambio de variable en ejecución, como un tema por
data-*del módulo 12. theme('spacing.4')da1rem, y no depende de--spacing. Si reduces la escala,desplaza-4no se entera, mientras que una utilidad con--spacing()sí.theme('spacing')trae una lista fija de 35 claves. Por esodesplaza-17no genera nada, a diferencia dep-17.
Si quieres seguir al tema en ejecución, escribe la variable: { color: 'var(--color-marca-500)' }.
14. Variantes y opciones en plugins
addVariant
Acepta los mismos tres formatos que @custom-variant, aunque con distinta sintaxis:
addVariant('activo', ['&:hover', '&:focus-visible']); // varios selectores
addVariant('impresion', '@media print'); // regla @
addVariant('anidada', { '@media print': { '&:hover': '@slot' } }); // objeto con @slot
.activo\:flex:hover { display: flex; }
.activo\:flex:focus-visible { display: flex; }
@media print {
.impresion\:flex { display: flex; }
.anidada\:flex:hover { display: flex; }
}
matchVariant: variantes con parámetro
Es lo que @custom-variant no puede hacer. La función recibe el valor, y opcionalmente el modificador:
matchVariant(
'estado',
(valor, { modifier }) => `&[data-estado="${valor}"]${modifier ? '.' + modifier : ''}`,
{ values: { pagado: 'pagado', enviado: 'enviado' } },
);
| Clase | CSS |
|---|---|
estado-pagado:flex | .estado-pagado\:flex[data-estado="pagado"] |
estado-[x]:flex | .estado-\[x\]\:flex[data-estado="x"] |
estado-pagado/foo:flex | .estado-pagado\/foo\:flex[data-estado="pagado"].foo |
estado-otro:flex | No genera nada |
not-estado-pagado:flex | .not-estado-pagado\:flex:not([data-estado="pagado"]) |
group-estado-pagado:flex | .group-estado-pagado\:flex:is(:where(.group)[data-estado="pagado"] *) |
Si el orden entre los valores importa, como en una variante con anchos, usa sort. La opción recibe a y b, con a.value y a.modifier, y funciona como el comparador de Array.prototype.sort:
matchVariant('hasta', (ancho) => `@media (width < ${ancho})`, {
values: { movil: '30rem', tableta: '48rem' },
sort: (a, b) => Number.parseFloat(b.value) - Number.parseFloat(a.value),
});
Plugins con opciones: plugin.withOptions()
withOptions envuelve el plugin en una función que recibe las opciones. El primer argumento devuelve el plugin, y el segundo, opcional, añade configuración al tema:
import plugin from 'tailwindcss/plugin';
export default plugin.withOptions(
(opciones = {}) =>
({ addUtilities }) => {
const prefijo = opciones.prefijo ?? 'x';
addUtilities({ [`.${prefijo}-caja`]: { borderRadius: `${opciones.radio ?? 4}px` } });
},
(opciones = {}) => ({
theme: { extend: { colors: { opc: opciones.color ?? 'red' } } },
}),
);
Desde CSS se pasan en un bloque con la sintaxis de declaraciones:
@plugin "./plugins/opciones.js" {
prefijo: "tnd";
radio: 8;
activo: true;
nulo: null;
lista: a, b;
color: #f00;
}
Esto es exactamente lo que recibe la función:
{ "prefijo": "tnd", "radio": 8, "activo": true, "nulo": null, "lista": ["a", "b"], "color": "#f00" }
Números, true, false y null se convierten a su tipo, y la coma crea un array. Hay dos errores que debes conocer:
| Código | Mensaje |
|---|---|
| Bloque anidado dentro de las opciones | `@plugin` options must be a flat list of declarations. |
Opciones a un plugin que no usa withOptions | The plugin "./plugins/fn.js" does not accept options |
plugin(fn, config) también admite un segundo argumento con configuración, sin opciones: plugin(fn, { theme: { extend: { colors: { extra: '#123456' } } } }) hace que exista bg-extra.
15. Compartir utilidades entre proyectos
Tienes tres niveles. Elige el menor que te sirva.
Un fichero CSS. Es lo que hemos hecho con utilidades-tienda.css. Dentro puede haber @theme, @utility, @custom-variant e incluso @plugin, que resuelve su ruta respecto a ese fichero. Basta importarlo después de Tailwind.
Un paquete de CSS. El mismo fichero, publicado en npm:
@import "tailwindcss";
@import "@empresa/utilidades/tienda.css";
Si el paquete trae componentes con clases en su HTML o JavaScript, recuerda que Tailwind no escanea node_modules. Debes añadir @source, como en el módulo 01.
Un plugin publicado. Solo si necesitas lógica u opciones. Un paquete con un index.js que exporte el plugin se carga como los oficiales: @plugin "@empresa/tailwind-tienda";.
16. Proyecto: el plugin de la tienda
Vamos a juntar todo en un panel de pedidos. La separación es la de la Figura 1: lo que pueda ser CSS va en utilidades-tienda.css y lo que necesite lógica, en un plugin.
Los datos de partida son los cuatro estados de los pedidos de la API de EMPRESA. Queremos, con una sola lista:
- Una clase
estado-*para cada estado, con sus colores claros y oscuros. - Una variante
pedido-*:por cada estado. - Si añadimos un estado, que todo aparezca solo.
Además, una barra de existencias cuyo color dependa del valor. Las dos cosas son imposibles con @utility: la primera necesita un bucle y la segunda, un if.
// plugins/tienda.js
import plugin from 'tailwindcss/plugin';
// Estado del pedido -> color de la paleta. Es la única fuente de la verdad.
const ESTADOS = {
pendiente: 'amber',
enviado: 'sky',
entregado: 'emerald',
cancelado: 'rose',
};
// Del 0 al 100 de 5 en 5: { 0: '0%', 5: '5%', ... }
const PORCENTAJES = Object.fromEntries(
Array.from({ length: 21 }, (_, i) => [i * 5, `${i * 5}%`]),
);
export default plugin.withOptions(
(opciones = {}) =>
({ addBase, addComponents, addUtilities, matchUtilities, matchVariant, theme }) => {
const radio = opciones.radio ?? theme('borderRadius.full');
// 1. Estilos base: Preflight deja los títulos sin formato
addBase({
'h1, h2': { fontWeight: theme('fontWeight.bold'), letterSpacing: '-0.01em' },
});
// 2. Un componente con varias reglas
addComponents({
'.insignia': {
display: 'inline-flex',
alignItems: 'center',
gap: theme('spacing.1'),
borderRadius: radio,
padding: `${theme('spacing.1')} ${theme('spacing.2')}`,
fontSize: '0.75rem',
fontWeight: theme('fontWeight.medium'),
},
});
// 3. Utilidades generadas con un bucle a partir de los datos
addUtilities(
Object.entries(ESTADOS).map(([estado, color]) => ({
[`.estado-${estado}`]: {
backgroundColor: theme(`colors.${color}.100`),
color: theme(`colors.${color}.800`),
'@media (prefers-color-scheme: dark)': {
backgroundColor: theme(`colors.${color}.900`),
color: theme(`colors.${color}.200`),
},
},
})),
);
// 4. Una utilidad funcional con lógica: el color depende del valor
matchUtilities(
{
stock: (valor) => {
const numero = Number.parseFloat(valor);
const color = numero < 20 ? 'rose' : numero < 50 ? 'amber' : 'emerald';
return { width: valor, backgroundColor: theme(`colors.${color}.500`) };
},
},
{ values: PORCENTAJES, type: 'percentage' },
);
// 5. Una variante funcional: pedido-enviado:, pedido-cancelado:...
matchVariant('pedido', (estado) => `&:where([data-estado="${estado}"], [data-estado="${estado}"] *)`, {
values: Object.fromEntries(Object.keys(ESTADOS).map((e) => [e, e])),
});
},
);
Lo cargamos con una opción para que el radio siga a la marca:
/* app.css */
@import "tailwindcss";
@import "./utilidades-tienda.css";
@plugin "./plugins/tienda.js" {
radio: 0.375rem;
}
<span class="insignia estado-enviado">Enviado</span>
<div class="h-3 w-64 overflow-hidden rounded-full bg-slate-200">
<div class="stock-35 h-full"></div>
</div>

Figura 16. El color de cada barra lo calcula el plugin a partir del número. stock-[37%] también funciona, con el mismo cálculo.
.stock-10 { width: 10%; background-color: oklch(...); }
.stock-\[37\%\] { width: 37%; background-color: oklch(...); }
Ahora, el panel. Todas las clases que no conoces de los módulos anteriores vienen de este módulo:
<main class="mx-auto max-w-3xl space-y-6">
<header class="flex items-center justify-between">
<h1 class="text-2xl">Pedidos</h1>
<span class="insignia estado-pendiente">1 por revisar</span>
</header>
<section class="rejilla-44 hueco-estrecho">
<article class="franja-marca-500 rounded-sm bg-white p-3 shadow-sm dark:bg-slate-900">
<h2 class="truncate text-sm">Portátil UltraBook 14</h2>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
<div class="stock-80 h-full"></div>
</div>
</article>
<!-- Más productos -->
</section>
<ul class="divide-y divide-slate-200 rounded-lg border border-slate-200 bg-white
dark:divide-slate-700 dark:border-slate-700 dark:bg-slate-900">
<li data-estado="cancelado"
class="flex flex-wrap items-center gap-x-4 gap-y-1 p-3
activo:bg-slate-50 pedido-cancelado:text-slate-400 dark:activo:bg-slate-800">
<span class="font-mono text-sm">5023</span>
<span class="min-w-40 flex-1 font-medium pedido-cancelado:line-through">Talleres Martín S.L.</span>
<span class="text-sm text-slate-500">12/11/2026</span>
<span class="insignia estado-cancelado">Cancelado</span>
<span class="w-24 text-right font-mono text-sm pedido-pendiente:font-bold">499,00 €</span>
</li>
<!-- Más pedidos -->
</ul>
</main>

Figura 17. El panel a 860 px. rejilla-44 deja sitio para una cuarta tarjeta.

Figura 18. Los estados cambian solos porque el plugin añade su bloque prefers-color-scheme.

Figura 19. A 400 px no hay ninguna clase responsive: rejilla-44 baja a una columna y flex-wrap parte las filas.
⚠️ Importante:
estado-*usa@media (prefers-color-scheme: dark)porque esa es la configuración por defecto, y porque así el plugin funciona también en 3.4. Si tu proyecto cambia a oscuro por clase (módulo 12), esa consulta no seguirá al conmutador. Cámbiala entonces por el selector que uses, o recíbelo como opción del plugin.
El mismo proyecto en 3.4
El cuerpo del plugin funciona sin cambios: solo cambia cómo se carga. Lo compilamos con 3.4.19 y genera las mismas clases, con los colores en hexadecimal:
.insignia { display: inline-flex; align-items: center; gap: 0.25rem; border-radius: 0.375rem; /* ... */ }
.estado-enviado { background-color: #e0f2fe; color: #075985; }
.stock-35 { width: 35%; background-color: #f59e0b; }
.pedido-enviado\:flex:where([data-estado="enviado"], [data-estado="enviado"] *) { display: flex; }
Lo que había en CSS, en cambio, debe ir a la configuración. Este es el equivalente de las utilidades de la tienda, con las mismas llamadas que hemos compilado en 3.4.19:
// tailwind.config.js (Tailwind 3.4)
const plugin = require('tailwindcss/plugin');
const flattenColorPalette = require('tailwindcss/lib/util/flattenColorPalette').default;
const tienda = require('./plugins/tienda');
module.exports = {
content: ['./*.html'],
theme: { extend: { colors: { marca: { 500: '#6366f1', 700: '#4338ca' } } } },
plugins: [
tienda({ radio: '0.375rem' }),
plugin(({ addVariant, matchUtilities, theme }) => {
matchUtilities(
{ rejilla: (v) => ({ display: 'grid', gridTemplateColumns: `repeat(auto-fill, minmax(min(${v}, 100%), 1fr))` }) },
{ values: theme('spacing'), type: 'length' },
);
matchUtilities(
{ franja: (v) => ({ borderInlineStart: `0.25rem solid ${v}` }) },
{ values: flattenColorPalette(theme('colors')), type: 'color' },
);
matchUtilities(
{ 'sombra-dura': (v, { modifier }) => ({ boxShadow: `${v} ${v} 0 ${modifier ?? 'currentColor'}` }) },
{
values: { DEFAULT: theme('spacing.1'), 2: theme('spacing.2'), 3: theme('spacing.3') },
modifiers: flattenColorPalette(theme('colors')),
},
);
addVariant('enviado', '&[data-estado="enviado"]');
addVariant('rebajas', ':where([data-campana="rebajas"]) &');
addVariant('apaisado', '@media (orientation: landscape)');
addVariant('activo', ['&:hover', '&:focus-visible']);
}),
],
};
Salida en 3.4.19, con franja-rose-500/40, sombra-dura-2/rose-500 y group-enviado:flex, todas funcionando:
.franja-rose-500\/40 { border-inline-start: 0.25rem solid rgb(244 63 94 / 0.4); }
.sombra-dura-2\/rose-500 { box-shadow: 0.5rem 0.5rem 0 #f43f5e; }
:where([data-campana="rebajas"]) .rebajas\:flex { display: flex; }
En 3.4 lo habitual es CommonJS, como en este tailwind.config.js. Para mantener el mismo estilo en tienda.js, cambia su import por const plugin = require('tailwindcss/plugin') y su export default por module.exports =. El resto del fichero queda igual.
17. @config: traer un tailwind.config.js a v4 🔄 v4.0
🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el fichero
tailwind.config.jsse detecta solo y@configes opcional, solo necesario si tienes varios. En 4.0 el fichero se ignora si no lo cargas. Si usas la LTS: no hace falta hacer nada;@config "./otra.config.js";sirve para elegir otro fichero.
v4 no busca un tailwind.config.js por su cuenta. Si quieres seguir usándolo, por ejemplo durante una migración, cárgalo a mano:
@import "tailwindcss";
@config "../tailwind.config.js";
La ruta es relativa al CSS, y como @plugin, no se puede anidar. Una ruta equivocada falla con Can't resolve './nada.js' in ....
Probamos qué opciones se siguen leyendo en 4.3.3:
| Opción | ¿Se lee en v4? | Comprobado |
|---|---|---|
theme, theme.extend | Sí | bg-marca genera #0000ff; theme.colors sin extend elimina la paleta |
plugins | Sí | Se ejecutan |
darkMode | Sí | 'selector' da .dark\:flex:where(.dark, .dark *) |
prefix | Sí, con la sintaxis de v4 | prefix: 'tw' da tw:flex, no tw-flex, y flex deja de generarse |
important | Sí | true añade !important a todo |
blocklist | Sí | blocklist: ['flex'] no genera flex |
content | Sí, como fuentes adicionales | No debería hacerte falta |
corePlugins | No, se ignora | float: false sigue generando float-left |
safelist | No, se ignora | Usa @source inline() (módulo 18) |
separator | No, se ignora | hover_flex no genera nada |
Cuando chocan el CSS y la configuración
Con colors: { marca: '#0000ff' } en la configuración y @theme { --color-marca: #ff0000; } en el CSS, gana el CSS. Los valores de la configuración sin conflicto se suman:
:root { --color-marca: #ff0000; }
.bg-marca { background-color: var(--color-marca); } /* del CSS */
.bg-solo { background-color: #00ff00; } /* de la configuración */
.p-hueco { padding: 13px; }
Observa .bg-solo: los valores que vienen de la configuración se escriben directos, sin variable, igual que los de un plugin. Esto tiene las mismas consecuencias que viste en la sección 13.
Lo razonable es tratar @config como un paso intermedio y mover poco a poco la configuración a CSS. El proceso completo está en el módulo 19. Dos @config en una misma hoja no dan error, y se aplican los dos.
18. Depuración
Cuando una utilidad propia no funciona, el síntoma suele ser que la clase no aparece en el CSS, sin error. Sigue este orden:
- Busca la clase en el CSS generado con las herramientas de desarrollo. Si está y no se ve, el problema es de cascada o de orden: revisa la sección 3. Si no está, sigue.
- Comprueba que Tailwind la vea. Tiene que aparecer completa en el código.
franja-${color}-500no sirve, como explicará el módulo 18. - Comprueba el tipo del valor. ¿
--value(integer)con-1.5? ¿[length]con(--var)? ¿Barra sin--modifier()? - Prueba con un caso mínimo, escribiendo la clase más simple que debería funcionar.
- Revisa el orden de carga:
@utilityy@themedeben estar en un CSS que se importe.
Esta tabla reúne lo visto en el módulo. Los mensajes son los reales:
| Síntoma o mensaje | Causa | Solución |
|---|---|---|
hover:x no genera nada | x está en @layer utilities | Usa @utility x |
Cannot apply unknown utility class | Clase de @layer o falta contexto | @utility, o @reference en un módulo aparte |
x-50/40 no genera nada | La utilidad no tiene --modifier() | Añádelo |
x-(--v) no genera nada | [length] no admite variables sin tipo | Escribe x-(length:--v) o usa [*] |
@utility x-* sin efecto tras actualizar | Desde 4.3 hace falta --value() | Añádelo |
--value(...) aparece en el CSS del navegador | Nombre sin -* | Añade -*, o elimina --value() |
Unsupported bare value data type: "-*" | --default() en versión anterior a 4.3 | Actualiza o quítalo |
Cannot use @variant with unknown variant: hover:focus | Sintaxis anterior a 4.3 | Actualiza o anida los @variant |
x:flex da color: red y no flex | Variante con cuerpo sin @slot | Añade @slot |
Tu utilidad pierde con p-2 o gana según el nombre | Orden alfabético | Sección 3 |
Sale 002pxred | --default() con espacios | Guárdalo en el tema |
Sale fontSize { en el CSS | theme('fontSize.2xl') devuelve un array | theme('fontSize.2xl.0') |
Sale 5000% en un color-mix | modifiers: 'any' en un color | Quita esa opción |
@apply en orden distinto del escrito | v4 ordena las clases | No mezcles clases que se pisen |
| Color vacío en un CSS Module | var() a mano sin respaldo y variable no emitida | --theme(--token) |
Failed to load the ES module | Falta "type": "module" | Añádelo o usa .mjs |
19. Ejercicios
Los ejercicios siguen en la tienda. Compila tu solución y, antes de abrir la que se propone, compara el CSS que genera.
Ejercicio 1: Contenido diferido
La portada de la tienda tiene muchas secciones. La propiedad content-visibility: auto permite al navegador no pintar las que están fuera de la pantalla, pero necesita un tamaño de reserva, contain-intrinsic-size: auto 20rem. Crea una utilidad contenido-diferido que junte las dos y compruébala con md:contenido-diferido.
Ver solución
@utility contenido-diferido {
content-visibility: auto;
contain-intrinsic-size: auto 20rem;
}
.contenido-diferido {
content-visibility: auto;
contain-intrinsic-size: auto 20rem;
}
@media (width >= 48rem) {
.md\:contenido-diferido {
content-visibility: auto;
contain-intrinsic-size: auto 20rem;
}
}
Con @utility las dos propiedades viajan juntas y la variante se aplica sin hacer nada. Si la hubieras escrito en @layer utilities, md:contenido-diferido no generaría nada en v4. En 3.4, esa es la forma correcta, y la variante funciona.
Ejercicio 2: Ancho de lectura
Crea una utilidad lectura-* que fije max-inline-size. Debe aceptar tres nombres del tema (corta: 45ch, normal: 65ch, amplia: 80ch) y cualquier longitud entre corchetes. lectura-enorme y lectura-[red] no deben generar nada.
Ver solución
@theme {
--lectura-corta: 45ch;
--lectura-normal: 65ch;
--lectura-amplia: 80ch;
}
@utility lectura-* {
max-inline-size: --value(--lectura-*, [length]);
}
:root { --lectura-corta: 45ch; --lectura-normal: 65ch; }
.lectura-\[50ch\] { max-inline-size: 50ch; }
.lectura-corta { max-inline-size: var(--lectura-corta); }
.lectura-normal { max-inline-size: var(--lectura-normal); }
lectura-enorme no coincide con ninguna variable, y [red] no es una longitud, así que ninguna genera nada. --lectura-amplia no aparece porque en esa compilación no se usaba lectura-amplia.
Ejercicio 3: Levantar al pasar el ratón
Crea levanta-*, que sube un elemento: levanta-1 sube una unidad de la escala y levanta-[3px], tres píxeles. Añade -levanta-*, que lo baja. Debe admitir levanta-0.5 y funcionar con hover:.
Ver solución
@utility levanta-* {
translate: 0 --spacing(--value(number) * -1);
translate: 0 calc(--value([length]) * -1);
}
@utility -levanta-* {
translate: 0 --spacing(--value(number));
translate: 0 --value([length]);
}
.-levanta-2 { translate: 0 calc(var(--spacing) * 2); }
.levanta-0\.5 { translate: 0 calc(var(--spacing) * 0.5 * -1); }
.levanta-1 { translate: 0 calc(var(--spacing) * 1 * -1); }
.levanta-\[3px\] { translate: 0 calc(3px * -1); }
Hay dos detalles importantes. number es lo que permite 0.5, porque integer no lo acepta. Y levanta sube porque el eje Y de CSS crece hacia abajo, por lo que el valor positivo de la clase se convierte en negativo en el CSS. Las clases con - son una utilidad distinta, y por eso se registra aparte.
Ejercicio 4: Subrayado de color
Crea subrayado-*, con un color como valor y el grosor en píxeles como modificador. subrayado-indigo-500 tiene 1 px, y subrayado-indigo-500/3, 3 px. Debe aceptar subrayado-[#f00]/2.
Ver solución
@utility subrayado-* {
text-decoration-line: underline;
text-decoration-color: --value(--color-*, [color]);
text-decoration-thickness: calc(--modifier(integer, --default(1)) * 1px);
text-underline-offset: 0.2em;
}
.subrayado-\[\#f00\]\/2 {
text-decoration-line: underline;
text-decoration-color: #f00;
text-decoration-thickness: calc(2 * 1px);
text-underline-offset: 0.2em;
}
.subrayado-indigo-500 {
text-decoration-line: underline;
text-decoration-color: var(--color-indigo-500);
text-decoration-thickness: calc(1 * 1px);
text-underline-offset: 0.2em;
}
El modificador aquí no es una opacidad, y no tiene por qué serlo: lo decide tu utilidad. calc(... * 1px) convierte el número en longitud. Con 4.0 a 4.2, en lugar de --default(1), tendrías que crear subrayado-* sin modificador y una línea aparte para el grosor.
Ejercicio 5: Aro de foco
Crea aro-*, con el ancho del contorno como valor y su separación como modificador. aro da 2 px y separación de 2 px, aro-4 da 4 px y aro-4/1 separa 1 px. Pruébala con focus-visible:aro.
Ver solución
@utility aro-* {
outline-style: solid;
outline-width: calc(--value(integer, --default(2)) * 1px);
outline-offset: calc(--modifier(integer, --default(2)) * 1px);
outline-color: var(--color-indigo-500);
}
.aro { outline-style: solid; outline-width: calc(2 * 1px); outline-offset: calc(2 * 1px); outline-color: var(--color-indigo-500); }
.aro-4\/1 { outline-style: solid; outline-width: calc(4 * 1px); outline-offset: calc(1 * 1px); outline-color: var(--color-indigo-500); }
.focus-visible\:aro:focus-visible { /* lo mismo que .aro */ }
aro funciona porque --default() hace válida la utilidad sin sufijo. Fíjate en que también se genera aro/0: el modificador puede ir solo.
En 3.4, la solución usa matchUtilities con values: { DEFAULT: '2px', 4: '4px' } y modifier ?? '2px'.
Ejercicio 6: Variantes de existencias
Las tarjetas de producto llevan data-stock con el número de unidades. Crea la variante agotado: para data-stock="0" y pantalla-baja: para una altura inferior a 30 rem. Comprueba si funcionan con group-*, peer-*, not-* y md:.
Ver solución
@custom-variant agotado (&[data-stock="0"]);
@custom-variant pantalla-baja (@media (height < 30rem));
.not-agotado\:flex:not([data-stock="0"]) { display: flex; }
.group-agotado\:line-through:is(:where(.group)[data-stock="0"] *) { text-decoration-line: line-through; }
.peer-agotado\:flex:is(:where(.peer)[data-stock="0"] ~ *) { display: flex; }
.agotado\:opacity-60[data-stock="0"] { opacity: 60%; }
@media (width >= 48rem) {
.md\:agotado\:flex[data-stock="0"] { display: flex; }
}
@media (height < 30rem) {
.pantalla-baja\:hidden { display: none; }
}
agotado funciona con todas, porque es un selector. pantalla-baja es una consulta de medios, y por eso group-pantalla-baja: no genera nada, aunque sí not-pantalla-baja:.
Observa también que agotado:hover:flex y hover:agotado:flex dan selectores distintos pero equivalentes: [data-stock="0"]:hover y :hover[data-stock="0"].
Ejercicio 7: Aviso de una librería
Una librería de avisos genera <div class="aviso-externo"> y no puedes cambiar su HTML. Hazle un fondo ámbar claro, y más oscuro en modo oscuro, con más relleno desde md. Al pasar el ratón o tener el foco dentro, un tono más intenso, en los dos modos. Usa @variant.
Ver solución
.aviso-externo {
padding: 1rem;
background-color: var(--color-amber-100);
@variant dark {
background-color: var(--color-amber-950);
}
@variant md {
padding: 1.5rem;
}
@variant hover, focus-within {
background-color: var(--color-amber-200);
@variant dark {
background-color: var(--color-amber-900);
}
}
}
.aviso-externo {
padding: 1rem;
background-color: var(--color-amber-100);
@media (prefers-color-scheme: dark) {
background-color: var(--color-amber-950);
}
@media (width >= 48rem) {
padding: 1.5rem;
}
&:hover {
@media (hover: hover) {
background-color: var(--color-amber-200);
@media (prefers-color-scheme: dark) {
background-color: var(--color-amber-900);
}
}
}
&:focus-within {
background-color: var(--color-amber-200);
@media (prefers-color-scheme: dark) {
background-color: var(--color-amber-900);
}
}
}
@variant hover, focus-within es de 4.3. Antes de esa versión tendrías que repetir el bloque. Escrito a mano, este CSS tendría que recordar además (hover: hover), y no seguiría un cambio futuro de dark.
Ejercicio 8: Depura este @apply
Este CSS viene de un proyecto v3 y no compila en 4.3.3. Explica el error, corrígelo y conserva .boton-primario como clase normal.
@layer components {
.boton {
padding: 0.5rem 1rem;
border-radius: var(--radius-lg);
}
}
.boton-primario {
@apply boton bg-indigo-600 text-white;
}
Ver solución
El mensaje real es:
Cannot apply unknown utility class `boton`
En v4, @apply solo acepta utilidades registradas, y una clase en @layer components no lo está. Hay que registrarla:
@utility boton {
padding: 0.5rem 1rem;
border-radius: var(--radius-lg);
}
.boton-primario {
@apply boton bg-indigo-600 text-white;
}
:root { --color-indigo-600: oklch(51.1% 0.262 276.966); --color-white: #fff; --radius-lg: 0.5rem; }
.boton-primario {
padding: 0.5rem 1rem;
border-radius: var(--radius-lg);
background-color: var(--color-indigo-600);
color: var(--color-white);
}
--radius-lg aparece en :root aunque ninguna clase use rounded-lg: en la hoja principal, Tailwind conserva las variables del tema que lees con var(). La regla .boton ya no sale en el CSS porque nadie usa la clase boton. Si la necesitas también en el HTML, escríbela allí y se generará.
Ejercicio 9: Fondo legible
Crea un plugin con una utilidad fondo-legible-* que reciba un color y elija el texto, oscuro o blanco, según la luminosidad del fondo. Debe aceptar colores del tema escritos en hexadecimal y valores como fondo-legible-[#0f766e].
Ver solución
Es un caso típico de plugin: hay un cálculo condicional, imposible con --value().
// plugins/legible.js
import plugin from 'tailwindcss/plugin';
import flattenColorPalette from 'tailwindcss/lib/util/flattenColorPalette';
function esClaro(hex) {
const n = hex.replace('#', '');
const largo = n.length === 3 ? n.split('').map((c) => c + c).join('') : n;
const [r, g, b] = [0, 2, 4].map((i) => Number.parseInt(largo.slice(i, i + 2), 16));
return 0.299 * r + 0.587 * g + 0.114 * b > 150;
}
export default plugin(({ matchUtilities, theme }) => {
const paleta = Object.fromEntries(
Object.entries(flattenColorPalette(theme('colors'))).filter(([, v]) => /^#[0-9a-f]{3,6}$/i.test(v)),
);
matchUtilities(
{
'fondo-legible': (valor) => ({
backgroundColor: valor,
color: esClaro(valor) ? '#111827' : '#ffffff',
}),
},
{ values: paleta, type: 'color' },
);
});
@theme {
--color-amarillo: #facc15;
--color-azul: #1e3a8a;
}
@plugin "./plugins/legible.js";

Figura 20. El plugin decide el color del texto al compilar.
.fondo-legible-\[\#fff\] { background-color: #fff; color: #111827; }
.fondo-legible-amarillo { background-color: #facc15; color: #111827; }
.fondo-legible-azul { background-color: #1e3a8a; color: #ffffff; }
El filtro de paleta es importante. La paleta de v4 está en oklch(), y esClaro solo sabe leer hexadecimal, por lo que fondo-legible-red-500 no se genera. Es mejor no generar la clase que generarla con un contraste equivocado.
La fórmula 0,299 / 0,587 / 0,114 es una aproximación de luminosidad. Para un cálculo preciso de accesibilidad, usa el contraste WCAG del módulo 20.
Ejercicio 10: De plugin a CSS
Reescribe este plugin en CSS todo lo que sea posible, y di qué no se puede pasar:
import plugin from 'tailwindcss/plugin';
export default plugin(function ({ addUtilities, matchUtilities, addVariant, matchVariant, theme }) {
addUtilities({ '.etiqueta-nueva': { backgroundColor: theme('colors.sky.100') } });
matchUtilities({ 'borde-a': (v) => ({ borderBottomWidth: v }) }, { values: theme('borderWidth') });
addVariant('pagina-actual', '&[aria-current="page"]');
matchVariant('hijo', (n) => '& > :nth-child(' + n + ')', { values: { 1: '1', 2: '2', 3: '3' } });
});
Ver solución
@utility etiqueta-nueva {
background-color: var(--color-sky-100);
}
@utility borde-a-* {
border-bottom-width: calc(--value(integer) * 1px);
}
@custom-variant pagina-actual (&[aria-current="page"]);
/* matchVariant no tiene equivalente: una variante por valor */
@custom-variant hijo-1 (& > :nth-child(1));
@custom-variant hijo-2 (& > :nth-child(2));
@custom-variant hijo-3 (& > :nth-child(3));
| Original | Salida del plugin | Salida en CSS |
|---|---|---|
etiqueta-nueva | oklch(95.1% 0.026 236.824) | var(--color-sky-100) |
borde-a-2 | 2px | calc(2 * 1px) |
pagina-actual:flex | Igual | Igual |
hijo-2:flex | Igual | Igual |
La versión CSS gana en dos cosas: etiqueta-nueva usa la variable, así que sigue a un tema en ejecución, y borde-a-* admite cualquier entero. Lo único que no se traduce bien es matchVariant: hijo-* tiene que repetirse, y se pierde hijo-[5]. Antes de conservar el plugin, mira si te basta lo nativo: *:nth-2:flex genera una regla equivalente sin escribir nada.
Ejercicio 11 (propuesto): Ficha con CSS Modules
Crea ficha.module.css con @reference, que use bg-marca-500, tu variante enviado: y @variant dark. Constrúyelo con vite build con @reference y con @import, y compara el tamaño de los dos CSS. Después, añade un var() a mano y un --theme() con dos tokens que no use ninguna clase, y mira en el CSS final si se definen y cuál lleva respaldo.
Ejercicio 12 (propuesto): Un estado nuevo
Añade a plugins/tienda.js el estado devuelto con la paleta violet. Comprueba que estado-devuelto y pedido-devuelto: existen sin tocar nada más. Después, convierte ESTADOS en una opción de @plugin y explica por qué no puedes hacerlo directamente con un objeto, a la vista del error de la sección 14.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Elegir herramienta | Figura 1 | De arbitrario a plugin, baja solo si hace falta |
| Utilidad estática | @utility x { ... } | Entra en el motor: variantes, !, @apply, solo si se usa |
Clase en @layer utilities | CSS normal | En v4 no admite variantes ni @apply |
| Orden | Sin API | Más propiedades, antes; a igualdad, alfabético |
| Utilidad funcional | @utility x-*, --value() | Tema, desnudo, literal y arbitrario |
| Varios casos | Declaraciones repetidas | Se descartan las que no se resuelven |
| Negativos | @utility -x-* | Se registran aparte |
| Modificador | --modifier() | Sin él, la clase con / no genera nada |
| Valor por defecto 🆕 v4.3 | --default() | Permite la clase sin sufijo; cuidado con los espacios |
| Variante | @custom-variant x (...) | Corta o con @slot; va detrás de las nativas |
| Combinaciones | group-x:, not-x: | group-* solo con selectores |
| Usar variantes en CSS | @variant | Sigue la configuración del proyecto |
| Apiladas y compuestas 🆕 v4.3 | @variant a:b, @variant a, b | Dos puntos: y. Coma: o |
| Copiar utilidades | @apply | Para HTML que no controlas; v4 ordena las clases |
| Otros ficheros | @reference | Consulta sin copiar; mejor --theme() que var() |
| Funciones | --spacing(), --alpha(), --theme() | theme() queda para compatibilidad |
| Cargar plugin | @plugin "ruta" { opciones; } | Lista plana de opciones |
| Crear plugin | plugin(), plugin.withOptions() | Para bucles, lógica y variantes con parámetro |
| Estilos base | addBase | Se escribe siempre |
| Utilidades en JS | addUtilities, matchUtilities | Con type: 'color' hay opacidad gratis |
| Componentes en JS | addComponents | En v4 van a la capa utilities |
| Variantes en JS | addVariant, matchVariant | matchVariant es lo único sin equivalente en CSS |
| Tema en JS | theme('colors.red.500') | Devuelve valores, no variables |
| Configuración heredada | @config | corePlugins, safelist y separator se ignoran |