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 @utility y 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 con group-*, peer-* y not-*
  • Aplicar variantes dentro de tu CSS con @variant, incluidas las apiladas y compuestas de la 4.3
  • Usar @apply con 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 con plugin() y plugin.withOptions()
  • Dominar la API de plugins: addBase, addUtilities, addComponents, matchUtilities, addVariant, matchVariant y theme()
  • Traer a v4 un tailwind.config.js con @config y 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 utilities o con la API de plugins en tailwind.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.

Seis preguntas en orden, cada una asociada a una herramienta: valor arbitrario, @theme, @utility, @custom-variant, CSS propio con @apply y plugin de JavaScript

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.css sin 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>

Tres tarjetas de producto. La segunda, agotada, tiene fondo a rayas; la tercera también, porque el ratón está encima

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 utilities y se sorprende: en v4 la clase sigue funcionando, pero hover:a-mano no 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>

Dos tiras de categorías con desplazamiento horizontal: la primera muestra la barra y la segunda no

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-barra ya 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 utilities van detrás de las nativas y ganan siempre: en abeto p-2 manda abeto. 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>

La caja con abeto y p-2 tiene poco relleno; la caja con zona y p-2 tiene mucho

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-2 deja ajustar el relleno de una utilidad grande.
  • Con el mismo número de declaraciones, el orden es alfabético. abeto va antes de p-2, y zona despué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 con matchUtilities() en un plugin. Si usas la LTS: escribe matchUtilities({ rejilla: (v) => ({ ... }) }, { values: theme('spacing') }) en tailwind.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>

Tres rejillas con los mismos seis productos: seis columnas con rejilla-32, tres con rejilla-56 y dos con rejilla-18rem

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 @utility con el mismo nombre y distinto tipo no se complementaban. En la LTS (Tailwind 3.4) @utility no existe. Si usas la LTS: llama a matchUtilities() una vez por grupo de valores, con su type.

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 de matchUtilities() recibe { modifier } como segundo argumento, y declaras los valores válidos con la opción modifiers. Con type: 'color', la opacidad /50 funciona 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.

La clase franja-rose-500/40 dividida en nombre, valor y modificador, con flechas hacia las funciones del código

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>

Cuatro tarjetas con una franja de color a la izquierda: índigo, rosa, rosa más claro y verde azulado

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ñade DEFAULT a values para el valor sin sufijo, como values: { DEFAULT: theme('spacing.1'), 2: theme('spacing.2') }, y para el modificador usa modifier ?? '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>

Cuatro tarjetas con sombra sólida: pequeña oscura, mayor oscura, rosa e índigo

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 a 002pxred, 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 clase enviado:flex no genera nada. Si usas la LTS: registra la variante en un plugin con addVariant('enviado', '&[data-estado="enviado"]'). En 3.4 se admiten selectores, un @media y 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>

Tres pedidos: entregado sin marcar, enviado resaltado en azul con la etiqueta En camino, cancelado gris y tachado

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>

Dos fichas del mismo monitor: sin el atributo cuesta 329 euros y el botón es índigo; con él, el precio está tachado, aparece 249 euros en rosa y el botón es rosa

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): @variant llega 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 @variant dentro 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) @apply respeta 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, @utility o de plugins. Si usas la LTS: tu @apply funciona igual, pero al migrar pasa a @utility las 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 @apply para 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. @apply está 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;
  }
}

Dos versiones del mismo texto: sin estilos todo queda plano por Preflight; con contenido-cms hay jerarquía, enlace y viñetas

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;
  }
}

Calendario de noviembre de 2026 en modo claro, con el día 10 seleccionado en índigo

Figura 12. El calendario en modo claro.

El mismo calendario con fondo oscuro

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 @apply funciona en CSS Modules y bloques <style> con el tema y los plugins. Las clases de @layer de 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;
}

Con @import el CSS final lleva app.css dos veces y pesa 56 226 bytes; con @reference lo lleva una vez y pesa 37 598

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 @reference es relativa al fichero. En proyectos con muchas carpetas, cuando ../../../app.css se vuelve incómodo, la documentación oficial recomienda los subpath imports de package.json, que crean un alias como @reference "#app.css". El módulo 18 aplica @reference en 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: usa theme(spacing.4), theme(colors.red.500 / 50%) y theme(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() necesita var() si le pasas una variable. --alpha(--color-red-500 / 50%) compila sin error a color-mix(in oklab, --color-red-500 50%, transparent), que no es CSS válido y el navegador descarta. Dentro de @utility es distinto: --value() ya devuelve el var(), 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) @plugin queda sin procesar: los plugins se registran en tailwind.config.js, y las opciones se pasan llamando a la función. Si usas la LTS: escribe plugins: [require('./plugins/tienda')({ radio: '0.375rem' })]. El cuerpo del plugin no cambia, y la función se importa con const 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

El plugin llama a addBase, addUtilities, addComponents, matchUtilities, addVariant y matchVariant, y cada una alimenta una parte distinta del CSS

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) addComponents escribe en la capa components, y cualquier utilidad lo sobrescribe. En 4.0 escribe en utilities, 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/50 compila a color-mix(in oklab, ... 5000%, transparent), un valor que no tiene sentido. Declara modifiers solo 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: escribe theme('fontSize.2xl.0'), que devuelve 1.5rem en 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') da 1rem, y no depende de --spacing. Si reduces la escala, desplaza-4 no se entera, mientras que una utilidad con --spacing() sí.
  • theme('spacing') trae una lista fija de 35 claves. Por eso desplaza-17 no genera nada, a diferencia de p-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>

Cuatro insignias de estado y tres barras de existencias: la de 10 en rosa, la de 35 en ámbar y la de 80 en verde

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>

Panel de pedidos en modo claro: tarjetas de producto con barra de existencias y lista de pedidos con insignias de estado

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

El mismo panel en modo oscuro

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

El panel a 400 píxeles de ancho, con las tarjetas apiladas y los pedidos en dos líneas

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.js se detecta solo y @config es 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:

  1. 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.
  2. Comprueba que Tailwind la vea. Tiene que aparecer completa en el código. franja-${color}-500 no sirve, como explicará el módulo 18.
  3. Comprueba el tipo del valor. ¿--value(integer) con -1.5? ¿[length] con (--var)? ¿Barra sin --modifier()?
  4. Prueba con un caso mínimo, escribiendo la clase más simple que debería funcionar.
  5. Revisa el orden de carga: @utility y @theme deben 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";

Cuatro etiquetas: amarilla con texto oscuro, azul con texto blanco, blanca con texto oscuro y verde oscura con texto blanco

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