Módulo 11: Variantes y estados

Objetivos del módulo

  • Entender qué es una variante, qué CSS genera y por qué el orden de las clases en el atributo no decide nada
  • Dar a un botón todos sus estados: hover, focus-visible, active y disabled, y saber en qué orden gana cada uno
  • Distinguir focus, focus-visible y focus-within
  • Conocer el cambio de hover en pantallas táctiles y cómo recuperar el comportamiento antiguo
  • Reflejar el estado de un formulario con checked, required, invalid, out-of-range y user-invalid
  • Dar estilo según la posición con first, last, odd, even, only, empty y nth-*
  • Hacer que un elemento reaccione a otro: group-* hacia los descendientes, peer-* hacia los hermanos, has-* hacia el contenedor e in-* desde cualquier ancestro
  • Excluir elementos con not-*
  • Dar estilo a los hijos desde el padre con *: y **:, y conocer su limitación
  • Generar pseudoelementos con before, after, marker, selection, file, placeholder y first-letter
  • Usar los atributos como estado con aria-* y data-*, y evitar la trampa de data-activo="false"
  • Reaccionar a <details> y a popover con open, y conocer details-content e inert
  • Leer una cadena de variantes en el sentido correcto, que cambia entre las versiones 3 y 4
  • Adaptar la interfaz al tipo de puntero con pointer-* y any-pointer-*

1. Preparación del laboratorio

Sigues con el proyecto Vite del módulo 01. Basta con este app.css, sin nada más:

@import "tailwindcss";

Los rótulos en letra de máquina que ves sobre los ejemplos de las capturas son solo leyendas: no aparecen en el código que se muestra. Los estados que dependen del ratón o del teclado se han forzado con las herramientas del navegador, para poder enseñar varios a la vez en una sola imagen. Tú puedes hacer lo mismo: en las herramientas de desarrollo, panel Elements, botón :hov, activas :hover, :focus-visible o :active sobre el elemento que tengas seleccionado.

⚠️ 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. Qué es una variante

Hasta ahora, cada clase aplicaba su estilo siempre. En el módulo 10 viste la primera excepción: md:flex solo actúa a partir de cierta anchura. Ese prefijo es una variante: una condición que se antepone a una utilidad, separada por dos puntos.

<button class="bg-indigo-600 hover:bg-indigo-700">Comprar</button>
.bg-indigo-600 {
  background-color: var(--color-indigo-600);
}

@media (hover: hover) {
  .hover\:bg-indigo-700:hover {
    background-color: var(--color-indigo-700);
  }
}

Fíjate en tres cosas:

  • hover:bg-indigo-700 es una clase más, cuyo nombre incluye los dos puntos. En CSS hay que escaparlos, y por eso aparece \:.
  • La variante no modifica bg-indigo-700: genera una regla nueva con esa condición.
  • Como con todas las utilidades, solo se genera lo que Tailwind encuentra en tus ficheros. Escribir hover:bg-indigo-700 en el HTML crea exactamente esa regla, y ninguna otra.

📘 Concepto: Una variante traduce un prefijo a una condición de CSS: una pseudoclase, un pseudoelemento, un selector de atributo, una consulta @media o @supports. Aprender variantes es, en buena parte, recordar qué CSS hay detrás de cada una, y lo que no se puede hacer en CSS tampoco se puede hacer con una variante.

Esta es la respuesta a una objeción habitual: “si el estilo va en el HTML, ¿no es lo mismo que el atributo style?”. No lo es, porque style no admite :hover, ::before ni @media. Las variantes te dan toda esa potencia sin salir de las clases.

Apilar variantes

Las variantes se pueden encadenar, y entonces deben cumplirse todas:

<button class="md:hover:bg-indigo-700">Comprar</button>

Las dos variantes de md:hover:bg-indigo-700 se convierten en dos consultas @media anidadas y la utilidad en la declaración

Figura 1. Cada variante añade una condición. La utilidad es siempre lo último de la clase.

Con variantes que son consultas, como aquí, el orden da igual: hover:md:flex y md:hover:flex piden lo mismo. Con las que cambian el elemento al que apunta el selector sí importa, y mucho. Lo verás en la sección 17.

El orden en el atributo class no cuenta

Este botón tiene las clases escritas “al revés”:

<button class="active:bg-red-500 focus:bg-green-500 hover:bg-blue-500">Probar</button>

Con el puntero encima es azul. Mientras lo pulsas es rojo. Al soltarlo, enfocado y con el puntero encima, es verde. Lo decide el orden en que Tailwind escribe las reglas en la hoja de estilos, que es fijo, y no el orden en que las escribes tú. El orden relevante es:

hover  →  focus  →  focus-visible  →  active  →  disabled

Ante igual especificidad gana la regla posterior. Por eso active se impone a focus y a hover, y disabled a todos ellos: en un botón desactivado con el puntero encima se ve el color de disabled.

Importante con variantes 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el signo ! va delante de la utilidad, no al final. Si usas la LTS: escribe hover:!flex en lugar de hover:flex!; en 3.4 hover:flex! genera la clase sin !important y sin avisar.

<div class="hover:flex!">...</div>
@media (hover: hover) {
  .hover\:flex\!:hover {
    display: flex !important;
  }
}

El signo nunca va delante de las variantes: !hover:flex no genera nada en ninguna de las dos versiones. Viste qué hace ! y cuándo evitarlo en el módulo 02.


3. Interacción: hover, focus y active

Empecemos por el componente que más estados tiene: un botón.

<button class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
               enabled:cursor-pointer
               hover:bg-indigo-700
               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
               active:bg-indigo-800
               disabled:bg-slate-300 disabled:text-slate-500">
  Añadir al carrito
</button>

Cinco copias del mismo botón: normal, más oscuro con hover, con un contorno separado con focus-visible, aún más oscuro con active y gris con disabled

Figura 2. El mismo botón en sus cinco estados. Un botón sin estos estados parece una imagen.

Variante CSS Cuándo actúa
hover: :hover El puntero está encima
focus: :focus El elemento tiene el foco, sea cual sea la causa
focus-visible: :focus-visible Tiene el foco y conviene mostrarlo, por ejemplo al llegar con el teclado
focus-within: :focus-within El foco está en el elemento o en un descendiente
active: :active Se está pulsando en este momento
disabled: :disabled Tiene el atributo disabled
enabled: :enabled Es un control que no está desactivado
visited: :visited Es un enlace ya visitado
target: :target Su id coincide con el fragmento # de la URL

focus frente a focus-visible

Si pones un anillo con focus: en un botón, aparecerá cada vez que hagas clic en él, y mucha gente acaba quitándolo por eso. Quitarlo deja sin indicación a quien navega con el teclado, que necesita saber en todo momento dónde está. focus-visible: resuelve el conflicto: el navegador decide si conviene mostrar el foco.

Con este botón se comprueba:

<button class="focus:bg-green-500 focus-visible:bg-blue-500">Probar</button>
  • Con un clic de ratón queda verde: tiene :focus, pero no :focus-visible.
  • Al llegar con la tecla Tab queda azul: tiene los dos, y gana focus-visible por ir después.

En los campos de texto, en cambio, :focus-visible se activa también con el ratón, porque ahí el usuario necesita ver dónde va a escribir.

💡 Consejo: Usa focus-visible: en botones y enlaces, y focus: en campos de formulario. Nunca elimines el indicador de foco sin poner otro en su lugar.

El anillo de foco 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) outline-2 solo fija el ancho, mientras que en v4 también fija el estilo. Si usas la LTS: añade outline, como en focus-visible:outline focus-visible:outline-2. Además, outline-hidden de v4 se llama outline-none en 3.4.

/* Tailwind 4.3 */
.focus-visible\:outline-2:focus-visible {
  outline-style: var(--tw-outline-style);
  outline-width: 2px;
}

/* Tailwind 3.4 */
.focus-visible\:outline-2:focus-visible {
  outline-width: 2px;
}

focus-within: el contenedor se entera

focus-within: reacciona al foco de cualquier descendiente. Sirve para resaltar una caja que envuelve a un campo:

<label class="flex items-center gap-2 rounded-lg border border-slate-300 px-3 py-2
              focus-within:border-indigo-600 focus-within:ring-2 focus-within:ring-indigo-200">
  <span class="text-slate-500">Buscar</span>
  <input class="w-full outline-hidden" placeholder="Portátil...">
</label>

Caja de búsqueda con el borde y el anillo resaltados mientras el campo interior tiene el foco

Figura 3. El foco lo tiene el input, pero se resalta la caja que lo envuelve.

disabled, y el enlace que no se puede desactivar

disabled: corresponde a :disabled, que solo existe en controles de formulario. Un <a> no admite el atributo disabled, de modo que disabled: nunca actúa en un enlace. Para los enlaces, el estado se expresa con ARIA y la clase correspondiente, y hay que bloquear además el clic:

<a href="#" aria-disabled="true"
   class="text-indigo-700 aria-disabled:pointer-events-none aria-disabled:text-slate-400">
  Descargar factura
</a>

pointer-events-none solo impide usarlo con el ratón: el enlace sigue siendo alcanzable con el teclado. Para quitarlo también de ahí, añade tabindex="-1", o mejor, no pongas un enlace si no lleva a ninguna parte.

El cursor de los botones 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En v4, Preflight deja cursor: default en los botones, y en la LTS (Tailwind 3.4) deja cursor: pointer. Si usas la LTS: no necesitas enabled:cursor-pointer; si acaso, añade disabled:cursor-not-allowed para indicar que no se puede pulsar.

Por eso el botón de arriba lleva enabled:cursor-pointer y no cursor-pointer a secas: en el navegador da pointer en el botón activo y default en el desactivado. Con cursor-pointer a secas, el botón desactivado seguiría prometiendo que se puede pulsar.

visited tiene límites

Por privacidad, los navegadores solo permiten cambiar con :visited unas pocas propiedades, las de color, y además no dejan consultar su valor desde JavaScript. Si no fuera así, una página podría averiguar tu historial midiendo los enlaces. Por eso visited:font-bold no funciona, y sí lo hace visited:text-purple-700.


4. hover en pantallas táctiles 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En v4, hover: solo se aplica en dispositivos con hover real, y en la LTS (Tailwind 3.4) se aplica también al tocar en una pantalla táctil. Si usas la LTS: activa future: { hoverOnlyWhenSupported: true } en tailwind.config.js, que genera @media (hover: hover) and (pointer: fine).

Un móvil no tiene puntero que pueda estar “encima” de nada. Aun así, al tocar un elemento, el navegador lo trata como si tuviera :hover, y ese estado se queda puesto hasta que tocas otra cosa. Es el llamado sticky hover: tocas un botón, cumple su función y se queda con el color de hover como si siguiera pulsado.

Tailwind 4 lo evita envolviendo cada hover: en una consulta de medios:

/* Tailwind 4.3 */
@media (hover: hover) {
  .hover\:bg-indigo-600:hover {
    background-color: var(--color-indigo-600);
  }
}

/* Tailwind 3.4 */
.hover\:bg-indigo-600:hover {
  --tw-bg-opacity: 1;
  background-color: rgb(79 70 229 / var(--tw-bg-opacity, 1));
}

Probado con un mismo botón bg-slate-200 hover:bg-indigo-600 en Chromium con emulación táctil real, donde (hover: hover) es falso:

Compilado con Color de fondo después de tocar
Tailwind 4.3.3 slate-200: no cambia
Tailwind 3.4.19 rgb(79, 70, 229): se queda en hover
3.4.19 con hoverOnlyWhenSupported rgb(226, 232, 240): no cambia

La consecuencia práctica es importante: lo que dependa solo de hover: no existe en un móvil. Si un menú se abre únicamente al pasar el puntero, o unas acciones solo se ven con group-hover:, el usuario táctil no llegará a ellas. En la sección 18 lo resolvemos.

También afecta a group-hover, peer-hover y has-hover, porque usan la misma condición. Si tu proyecto depende del comportamiento antiguo, puedes redefinir la variante en app.css:

@import "tailwindcss";

@custom-variant hover (&:hover);

Con eso, hover:flex vuelve a generar .hover\:flex:hover sin la consulta. Es una medida de transición: @custom-variant se explica en el módulo 15.


5. Estados de formulario

Los campos tienen muchos estados propios, casi todos con su variante:

Variante CSS Significado
checked: :checked Casilla o radio marcados
indeterminate: :indeterminate Casilla en estado intermedio (se fija con JavaScript)
default: :default Era la opción marcada por defecto
required: :required Tiene el atributo required
optional: :optional No tiene required
valid: :valid El valor pasa la validación
invalid: :invalid El valor no pasa la validación
in-range: :in-range Dentro de min y max
out-of-range: :out-of-range Fuera de min y max
placeholder-shown: :placeholder-shown Está vacío y muestra el placeholder
read-only: :read-only No se puede editar
autofill: :autofill Rellenado por el navegador
<input required placeholder="Nombre del producto"
       class="w-full rounded-lg border border-slate-300 px-3 py-2
              required:border-l-4 required:border-l-indigo-500">

<input readonly value="SKU-00125"
       class="w-full rounded-lg border border-slate-300 px-3 py-2
              read-only:bg-slate-100 read-only:text-slate-500">

<input type="number" min="0" max="100" value="150"
       class="w-full rounded-lg border border-slate-300 px-3 py-2
              out-of-range:border-amber-500 out-of-range:bg-amber-50">

<input disabled value="No editable"
       class="w-full rounded-lg border border-slate-300 px-3 py-2
              disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400">

<input type="checkbox" checked
       class="size-5 appearance-none rounded border-2 border-slate-400
              checked:border-indigo-600 checked:bg-indigo-600">

Rejilla de campos: invalid en rojo nada más cargar, user-invalid sin marcar, required con barra lateral, read-only gris, out-of-range en ámbar, disabled gris, placeholder en cursiva y casilla marcada

Figura 4. Cada campo refleja su estado sin una línea de JavaScript. Compara los dos de arriba: tienen el mismo valor incorrecto.

La casilla usa appearance-none para quitar su aspecto nativo. Con eso desaparece también la marca de verificación, así que en un proyecto real necesitas dibujarla. El módulo 17 lo trata a fondo.

El problema de invalid

Una regla CSS con :invalid se aplica desde que se carga la página. El usuario abre un formulario con un campo obligatorio vacío y lo primero que ve es un error, antes de haber escrito nada. Es una experiencia desagradable: te regaña por algo que aún no has hecho.

user-valid y user-invalid 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe la pseudoclase como variante arbitraria: [&:user-invalid]:border-red-500 y [&:user-valid]:border-green-500, que generan el mismo CSS.

Estas variantes corresponden a :user-invalid y :user-valid, que esperan a que el usuario haya interactuado con el campo:

<input type="email" required value="ana@"
       class="w-full rounded-lg border border-slate-300 px-3 py-2
              user-invalid:border-red-500 user-invalid:bg-red-50">

Este es el comportamiento medido en Chromium, paso a paso:

Momento invalid: user-invalid:
Al cargar la página Rojo Sin marcar
Escribiendo, sin salir del campo Rojo Sin marcar
Tras salir del campo con un valor incorrecto Rojo Rojo

Entrar en un campo vacío y salir sin escribir tampoco lo marca. Es lo que el usuario espera: el error aparece cuando ha terminado de escribir, y no antes.


6. Posición entre hermanos

Estas variantes dependen del lugar del elemento dentro de su padre:

Variante CSS Selecciona
first: :first-child El primer hijo
last: :last-child El último hijo
only: :only-child El hijo único
odd: :nth-child(odd) Las posiciones impares
even: :nth-child(even) Las posiciones pares
first-of-type: :first-of-type El primero de su etiqueta
last-of-type: :last-of-type El último de su etiqueta
only-of-type: :only-of-type El único de su etiqueta
empty: :empty No tiene contenido

El caso clásico es una tabla con filas alternas:

<tbody>
  <tr class="border-b border-slate-200 last:border-b-0 even:bg-slate-50 hover:bg-indigo-50">
    <td class="py-2 pl-3 font-medium">Portátil UltraBook 14</td>
    <td>Portátiles</td>
    <td class="pr-3 text-right">899 €</td>
  </tr>
  <!-- ...más filas con las mismas clases... -->
</tbody>

Tabla de productos con filas pares sombreadas, una fila resaltada por hover y sin borde en la última

Figura 5. Sin esas variantes tendrías que generar una clase distinta por fila desde el servidor. La tercera fila tiene el puntero encima.

Todas las filas llevan las mismas clases, y en cada una se cumplen solo las que le corresponden: even: está también en las filas impares, donde no hace nada. Esto encaja bien con un bucle que genera las filas, donde todas reciben la misma cadena de clases.

first no es “el primero de los míos”

first: significa “soy el primer hijo de mi padre”, sea cual sea la etiqueta. Es un error común:

<div>
  <h3>Título</h3>
  <p class="first:text-red-600 first-of-type:font-bold">Primer párrafo</p>
  <p class="first-of-type:font-bold">Segundo párrafo</p>
</div>

El primer párrafo sale en negrita, pero no en rojo: es el primero de su tipo, y sin embargo el primer hijo es el <h3>.

Lo mismo pasa con even: en una tabla: cuenta dentro de su padre, el <tbody>, y por tanto la fila de <thead> no entra en la cuenta.

empty

Quita del diseño un elemento sin contenido. Es útil para contadores que a veces no tienen nada que mostrar:

<span class="rounded-full bg-red-600 px-2 text-xs text-white empty:hidden"></span>

Un solo espacio ya basta para que deje de estar vacío: al poner ' ' como contenido, el elemento vuelve a mostrarse. Vigílalo si tu plantilla deja un salto de línea entre las etiquetas.

Posiciones concretas con nth-* 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa una variante arbitraria: [&:nth-child(3)]:bg-indigo-200 en lugar de nth-3:bg-indigo-200. Funciona igual con nth-last-child y nth-of-type.

Variante CSS
nth-3: :nth-child(3)
nth-last-3: :nth-last-child(3)
nth-of-type-3: :nth-of-type(3)
nth-last-of-type-2: :nth-last-of-type(2)
nth-[3n+1]: :nth-child(3n+1)
nth-[2n+1_of_li]: :nth-child(2n+1 of li)
<!-- Migas de pan: los extremos son distintos -->
<ul class="flex text-sm">
  <li class="border border-l-0 border-slate-300 px-3 py-1
             first:rounded-l-lg first:border-l last:rounded-r-lg last:bg-indigo-600 last:text-white">
    Inicio
  </li>
  <!-- ...Portátiles, Gaming, UltraBook 14, con las mismas clases... -->
</ul>

<!-- Cuadrícula de ocho elementos con las mismas clases en todos -->
<ul class="grid grid-cols-4 gap-2 text-center text-sm">
  <li class="rounded-sm bg-slate-100 p-2 nth-3:bg-indigo-200 nth-last-2:bg-orange-200 nth-[4n+1]:font-bold">1</li>
  <!-- ...hasta el 8... -->
</ul>

Migas de pan con los extremos redondeados y el último resaltado; cuadrícula con el 3 en azul, el 7 en naranja y el 1 y el 5 en negrita

Figura 6. Arriba, first y last. Abajo, nth-3, nth-last-2 (el séptimo de ocho) y nth-[4n+1] (el 1 y el 5).

Dentro de los corchetes, los espacios se escriben con guion bajo: 2n+1_of_li se convierte en 2n+1 of li.


7. Reaccionar a otro elemento: visión general

Hasta aquí, cada elemento reaccionaba a su propio estado. Lo interesante empieza cuando un elemento cambia por lo que le pasa a otro. En CSS eso se hace con combinadores y con :has(), y Tailwind lo reparte en cuatro familias según la relación entre los dos elementos:

Cuatro esquemas: group va del ancestro marcado al descendiente, peer del hermano anterior al siguiente, has del contenido al contenedor e in de cualquier ancestro al elemento

Figura 7. La pregunta que decide la variante es: ¿dónde está el elemento que manda respecto al que cambia?

Quiero que… Uso
Un hijo cambie cuando su contenedor cambia group y group-*
Un elemento cambie según un hermano anterior peer y peer-*
Un contenedor cambie según lo que contiene has-*
Un elemento cambie dentro de cualquier ancestro que cumpla algo in-*

8. group: el padre manda

Cuando pasas el puntero por una tarjeta quieres que cambien varias cosas a la vez: el título, la imagen, un enlace. Con hover: en cada hijo, cada uno reaccionaría solo cuando el puntero esté justo sobre él. Hace falta que reaccionen al hover de la tarjeta.

Se hace en dos pasos: marcas al padre con la clase group, y usas group-hover: en los descendientes.

<a href="#" class="group block w-60 overflow-hidden rounded-xl border border-slate-200 bg-white
                   hover:border-indigo-400 hover:shadow-lg">
  <div class="flex h-24 items-center justify-center bg-slate-100 text-3xl font-bold text-slate-400
              group-hover:bg-indigo-50 group-hover:text-indigo-500">14"</div>
  <div class="p-4">
    <h3 class="font-semibold text-slate-800 group-hover:text-indigo-700">Portátil UltraBook 14</h3>
    <p class="text-sm text-slate-500">899 €</p>
    <span class="mt-2 inline-block text-sm text-indigo-700 opacity-0 group-hover:opacity-100">Ver ficha</span>
  </div>
</a>
@media (hover: hover) {
  .group-hover\:text-indigo-700:is(:where(.group):hover *) {
    color: var(--color-indigo-700);
  }
}

El selector se lee así: “este elemento, si está dentro de un .group con :hover”. La clase group por sí sola no genera ninguna regla: es una marca que solo existe para que la use ese selector. :where() tiene especificidad cero, por lo que .group no encarece la regla.

Tarjeta de producto normal y con el puntero encima, con la imagen, el título y el enlace cambiados a la vez; a la derecha, un pedido con grupos anidados

Figura 8. Un solo hover cambia cinco cosas. A la derecha, dos grupos con nombre: el título reacciona a la tarjeta, y “Quitar” solo a su fila.

Todo estado admite el prefijo group-: group-focus-within:, group-disabled:, group-odd:, group-open:, group-aria-expanded:, group-data-[abierto]:… También hay forma arbitraria, para comprobar un selector sobre el grupo:

<div class="group activo">
  <span class="group-[.activo]:text-red-600">Se pone rojo si el grupo tiene .activo</span>
</div>

Grupos anidados: ponles nombre

¿Qué pasa si hay un group dentro de otro? Sin nombre, el selector dice “dentro de algún .group”, y vale cualquiera:

<div class="group p-4">
  <p>Exterior</p>
  <div class="group p-4">
    <p>Interior</p>
    <span class="group-hover:text-red-600">Texto</span>
  </div>
</div>

Con el puntero sobre “Exterior”, fuera del grupo interior, el texto se pone rojo igualmente. Para ser precisos, añade un nombre tras una barra, tanto en la marca como en la variante:

<div class="group/tarjeta rounded-xl border border-slate-200 p-3 hover:border-indigo-400">
  <h3 class="font-semibold group-hover/tarjeta:text-indigo-700">Pedido 5001</h3>
  <ul class="mt-2 text-sm">
    <li class="group/fila flex justify-between rounded-sm px-2 py-1 hover:bg-slate-100">
      <span>Portátil UltraBook 14</span>
      <button class="text-red-600 opacity-0 group-hover/fila:opacity-100">Quitar</button>
    </li>
  </ul>
</div>
.group-hover\/fila\:opacity-100:is(:where(.group\/fila):hover *) { opacity: 100%; }

group-focus casi nunca es lo que quieres

Un error muy común: un contenedor marcado con group que tiene un enlace dentro, y un group-focus: en otro hijo. No funciona, porque el foco lo tiene el enlace, no el contenedor, y :focus no se propaga hacia arriba.

<article class="group">
  <a href="#">Ver pedido</a>
  <p class="group-focus:text-red-600">No cambia nunca</p>
  <p class="group-focus-within:text-red-600">Cambia al enfocar el enlace</p>
</article>

Al enfocar el enlace, solo cambia el segundo párrafo. Usa group-focus-within:.


9. peer: el hermano manda

peer funciona como group, pero entre hermanos: marcas un elemento con peer y otro reacciona con peer-*.

<label class="flex items-center gap-3">
  <input type="checkbox" class="peer sr-only">
  <span class="relative h-6 w-11 rounded-full bg-slate-300 peer-checked:bg-indigo-600
               peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600
               after:absolute after:top-0.5 after:left-0.5 after:size-5 after:rounded-full after:bg-white
               peer-checked:after:translate-x-5"></span>
  Recibir ofertas
</label>

Es un interruptor hecho sin JavaScript. La casilla real sigue ahí, oculta con sr-only y no con hidden, para que funcione con el teclado y los lectores de pantalla. Se comprueba enfocándola y pulsando la barra espaciadora: se marca. El <span> solo la dibuja, y peer-focus-visible: le devuelve el indicador de foco que la casilla oculta ya no puede mostrar.

.peer-checked\:bg-indigo-600:is(:where(.peer):checked ~ *) {
  background-color: var(--color-indigo-600);
}

El combinador ~ es la clave de la limitación de peer.

⚠️ Importante: El peer debe ir antes que el elemento que reacciona, y en el mismo padre. En CSS, ~ solo selecciona hermanos posteriores. Si colocas el campo después del texto, peer-checked: no hace nada, sin error ni aviso. Si necesitas la etiqueta a la izquierda, pon el campo primero en el HTML y cambia el orden visual con order-*, o usa has-*.

Interruptores apagado y encendido, etiqueta flotante en sus dos posiciones, campo con mensaje y radios que muestran un texto u otro

Figura 9. Cuatro patrones hechos solo con peer.

Etiqueta flotante

Una técnica muy conocida: la etiqueta ocupa el lugar del placeholder y sube al escribir.

<div class="relative">
  <input id="correo" placeholder=" "
         class="peer w-full rounded-lg border border-slate-300 px-3 pt-5 pb-1.5
                focus:border-indigo-600 focus:outline-hidden">
  <label for="correo"
         class="absolute top-1 left-3 text-xs text-slate-500
                peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base
                peer-focus:top-1 peer-focus:text-xs peer-focus:text-indigo-600">
    Correo electrónico
  </label>
</div>

El truco está en placeholder=" ". El texto del placeholder es un espacio invisible, pero existe, y por eso :placeholder-shown indica con exactitud si el campo está vacío:

Situación top de la etiqueta Por qué
Vacío y sin foco 14px, tamaño grande peer-placeholder-shown:
Con foco 4px, tamaño pequeño peer-focus: va después y gana
Con texto, sin foco 4px, tamaño pequeño Sin placeholder visible: clases base

Varios peer con nombre

Igual que con los grupos, puedes distinguir hermanos con un nombre. Aquí dos radios muestran cada uno su propio texto:

<input id="e1" type="radio" name="envio" class="peer/estandar" checked>
<label for="e1">Estándar</label>
<input id="e2" type="radio" name="envio" class="peer/urgente">
<label for="e2">Urgente</label>

<p class="hidden text-sm peer-checked/estandar:block">Llega en 3-5 días. Gratis.</p>
<p class="hidden text-sm peer-checked/urgente:block">Llega mañana. 9,90 €.</p>

Al marcar “Urgente”, el primer párrafo pasa a display: none y el segundo a block.

Mensaje de error ligado al campo 🆕 v4.1

🆕 Desde Tailwind 4.1. peer-user-invalid: no existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe peer-[:user-invalid]:block, que genera un selector equivalente.

<input type="email" required value="ana@"
       class="peer w-full rounded-lg border border-slate-300 px-3 py-2 user-invalid:border-red-500">
<p class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">Escribe un correo válido.</p>

El mensaje empieza con display: none, y pasa a block después de que el usuario salga del campo con un valor incorrecto. Con peer-invalid: estaría visible desde el principio, como puedes ver en la figura 9.


10. has-*: el contenido manda

Durante años no había forma de hacer esto en CSS: dar estilo a un elemento según lo que tiene dentro. :has() lo permite, y es la pieza que hace innecesario mucho JavaScript.

<label class="flex cursor-pointer items-start gap-3 rounded-xl border-2 border-slate-200 p-4
              has-[:checked]:border-indigo-600 has-[:checked]:bg-indigo-50
              has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2">
  <input type="radio" name="plan" class="mt-1">
  <span>
    <strong class="block">Envío estándar</strong>
    <span class="text-sm text-slate-500">3-5 días, 4,90 €</span>
  </span>
</label>
.has-\[\:checked\]\:bg-indigo-50:has(:is(:checked)) {
  background-color: var(--color-indigo-50);
}

No hace falta marcar a nadie, ni que el radio vaya delante de nada. La tarjeta pregunta: “¿tengo dentro algo marcado?”.

Tres tarjetas de envío con la marcada resaltada; dos artículos, uno a dos columnas por tener imagen; y un bloque rojo por tener un campo no válido

Figura 10. has-* hace que el contenedor reaccione a una selección, a la existencia de una imagen o a la validez de un campo.

Entre corchetes va cualquier selector:

Clase Pregunta
has-[:checked]: ¿Hay algo marcado dentro?
has-[:invalid]: ¿Hay algún campo no válido dentro?
has-[:focus-visible]: ¿Hay algo enfocado con foco visible dentro?
has-[img]: ¿Hay una imagen dentro?
has-[>img]: ¿Hay una imagen como hijo directo?
has-[>li>a]: ¿Hay un enlace justo dentro de un li hijo?

La variante con combinador se comprueba fácilmente: has-[>li>a]: se cumple con <ul><li><a>, pero no con <ul><li><span><a>.

El diseño depende del contenido

Con has-[img]: una misma tarjeta se adapta a tener imagen o no, sin una clase distinta:

<article class="grid gap-3 rounded-xl border border-slate-200 p-3 has-[img]:grid-cols-[6rem_1fr]">
  <img src="monitor.png" alt="" class="rounded-lg">
  <div>
    <h3 class="font-semibold">Monitor 27 4K</h3>
    <p class="text-sm text-slate-500">Con imagen: dos columnas</p>
  </div>
</article>

En el navegador, la versión con imagen resuelve las columnas como 96px 244px, y la versión sin imagen, con el mismo HTML salvo el <img>, como una sola columna de 352px.

group-has-* y peer-has-*

Se pueden combinar. Con group-has-*, un hijo reacciona a lo que contiene el grupo, aunque no sea hermano suyo ni contenga nada él mismo:

<div class="group rounded-lg p-3 has-[:invalid]:bg-red-50">
  <h3 class="font-semibold group-has-[:invalid]:text-red-700">Datos de envío</h3>
  <input required class="mt-1 w-full rounded-sm border border-slate-300 px-2 py-1" placeholder="Código postal">
</div>

Aquí el título va antes que el campo, y aun así se pone rojo mientras el campo no sea válido. peer-* no podría hacerlo, porque el campo va después. Escribir 41001 en el campo devuelve el título a su color.

Variantes con nombre 🆕 v4.0

🆕 Desde Tailwind 4.0. has-checked:, has-focus:, has-hover: y similares no existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe la forma con corchetes, has-[:checked]:, que funciona igual en las dos versiones.

v4 acepta tras has- cualquier variante de pseudoclase, sin corchetes. Equivalen a la forma larga, y además has-hover: añade la consulta (hover: hover). En este módulo usamos la forma con corchetes porque te vale para las dos versiones.


11. in-*: cualquier ancestro 🆕 v4.0

🆕 Desde Tailwind 4.0. in-* no existe en la LTS (Tailwind 3.4). Si usas la LTS: marca al ancestro con group y usa group-*, o escribe una variante arbitraria, como [[data-seccion=ofertas]_&]:text-orange-600.

in-* es como group-* sin el group: no hay que marcar al ancestro. Comprueba si alguno de los ancestros cumple la condición.

<section data-seccion="ofertas">
  <div>
    <p>
      <span class="font-medium in-data-[seccion=ofertas]:text-orange-600">899 €</span>
      dentro de la sección de ofertas
    </p>
  </div>
</section>
:where([data-seccion="ofertas"]) .in-data-\[seccion\=ofertas\]\:text-orange-600 {
  color: var(--color-orange-600);
}

Con in-*, el componente lleva consigo su adaptación, y cualquier sección puede activarla con solo poner el atributo. Esa es su mejor aplicación: que un componente se adapte al contexto sin conocer su estructura.

Lista con separadores entre filas, tres botones con uno activo y el mismo precio en naranja dentro de la sección de ofertas y sin color fuera

Figura 11. Arriba, not-*. Abajo, el mismo elemento con las mismas clases, dentro y fuera de [data-seccion=ofertas].

⚠️ Importante: in-hover: e in-focus-within: casi nunca hacen lo que parece. <html> y <body> son ancestros de todo, y tienen :hover siempre que el puntero esté en la página. Se ha comprobado: un elemento con in-hover:text-red-600 se pone rojo con el puntero en cualquier otro sitio. Usa in-* con condiciones que solo cumpla el ancestro que buscas, como un atributo, y group-hover: para el hover de un contenedor concreto. in-focus: sí funciona, porque el foco solo está en un elemento: se activa dentro de él y no fuera.

group o in

  group-* in-*
Hay que marcar al ancestro Sí No
Permite elegir qué ancestro Sí, con nombre Solo por la condición
Sirve para hover y focus-within Sí No, por <html> y <body>
En 3.4 Sí No

12. not-*: lo contrario 🆕 v4.0

🆕 Desde Tailwind 4.0. not-* no existe en la LTS (Tailwind 3.4). Si usas la LTS: usa una variante arbitraria: [&:not(:last-child)]:border-b en lugar de not-last:border-b, y en general [&:not(...)]:. Las negaciones de consultas (not-md:) se sustituyen por max-md:.

not-* niega otra variante. Es la forma limpia de escribir “todos menos…”:

<ul class="rounded-lg border border-slate-200">
  <li class="px-3 py-2 not-last:border-b not-last:border-slate-200">Portátiles</li>
  <li class="px-3 py-2 not-last:border-b not-last:border-slate-200">Monitores</li>
  <li class="px-3 py-2 not-last:border-b not-last:border-slate-200">Periféricos</li>
</ul>

Compáralo con la versión de la tabla de la sección 6: en vez de poner un borde y quitárselo al último con last:border-b-0, nunca se lo pones.

Admite casi cualquier variante, y genera lo que corresponde según su tipo:

Clase CSS que genera
not-first: :not(:first-child)
not-disabled: :not(:disabled)
not-[.activo]: :not(:is(.activo))
not-has-[a]: :not(:has(:is(a)))
not-data-activo: :not([data-activo])
not-aria-expanded: :not([aria-expanded="true"])
not-md: @media not (width >= 48rem)
not-supports-[display:grid]: @supports not (display:grid)
not-dark: @media not (prefers-color-scheme: dark)

Se apila con las demás, y ahí está gran parte de su utilidad. not-disabled:hover:bg-blue-500 da el hover solo si el botón no está desactivado: en la práctica, con disabled: ya resuelves ese caso, pero es útil cuando el estado desactivado no debe tener color propio.

not-hover se aplica también en táctil

.not-hover\:flex:not(:hover) {
  display: flex;
}
@media not (hover: hover) {
  .not-hover\:flex {
    display: flex;
  }
}

Tailwind genera dos reglas porque hover: lleva la consulta (hover: hover), de forma que la negación completa es “no estoy en hover, o este dispositivo no tiene hover”. Es exactamente lo que necesitas para algo que se ve salvo con el puntero encima: en un móvil se ve siempre.


13. Dar estilo a los hijos desde el padre

*: para los hijos directos

Cuando todos los hijos llevan las mismas clases, puedes escribirlas una sola vez en el padre:

<ul class="flex flex-wrap gap-2 *:rounded-full *:bg-indigo-50 *:px-3 *:py-1 *:text-sm *:text-indigo-700">
  <li>Portátiles</li>
  <li>Monitores</li>
  <li>Periféricos</li>
  <li class="bg-orange-500 text-white">Ofertas</li>
  <li class="bg-orange-500! text-white!">Liquidación</li>
</ul>
:is(.\*\:bg-indigo-50 > *) {
  background-color: var(--color-indigo-50);
}

Pero mira el cuarto elemento en la figura: lleva bg-orange-500, y no se ve naranja.

Etiquetas redondeadas iguales salvo la última, naranja; a la derecha enlaces subrayados a distintas profundidades; abajo, listas con estilo desde el padre

Figura 12. “Ofertas” lleva bg-orange-500, y *:bg-indigo-50 le gana. Solo “Liquidación”, con !, consigue el naranja.

⚠️ Importante: Una utilidad en el hijo no puede sobrescribir una clase *: del padre. Las dos reglas tienen la misma especificidad, porque * no suma, y las reglas con variante se escriben después de las normales, así que gana *:. Es el precio de esa comodidad: si los hijos pueden necesitar excepciones, pon las clases en cada hijo, o usa bg-orange-500! en el hijo (!bg-orange-500 en 3.4).

**: para todos los descendientes 🆕 v4.0

🆕 Desde Tailwind 4.0. **: no existe en la LTS (Tailwind 3.4). Si usas la LTS: usa una variante arbitraria con el descendiente: [&_[data-enlace]]:underline en lugar de **:data-[enlace]:underline.

**: llega a cualquier profundidad. Sin más condiciones es demasiado amplio, así que lo normal es apilarlo con otra variante:

<nav class="**:data-[enlace]:text-indigo-700 **:data-[enlace]:underline">
  <ul class="space-y-1">
    <li><a href="#" data-enlace>Mis pedidos</a></li>
    <li><span><a href="#" data-enlace>Mis direcciones</a></span></li>
    <li><a href="#">Cerrar sesión</a></li>
  </ul>
</nav>

Los dos primeros enlaces quedan subrayados, aunque estén a distinta profundidad; el tercero, sin el atributo, no.

Variantes arbitrarias

Cuando no hay variante para lo que necesitas, escribe el selector tú mismo entre corchetes. & representa al elemento y _ a un espacio:

Clase Selector generado Para qué
[&>li]:pl-2 .clase > li Hijos directos concretos
[&_p]:text-sm .clase p Descendientes concretos
[&.activo]:bg-indigo-600 .clase.activo Una clase propia del elemento
[.activo_&]:flex .activo .clase Un ancestro concreto
[@media(pointer:coarse)]:flex @media (pointer:coarse) Cualquier consulta

Son una salida de emergencia útil para contenido que no controlas, como el HTML de un editor de texto. En [&>li]:border-l-4, los <li> no llevan ninguna clase, y aun así reciben el borde. Si repites la misma variante arbitraria a menudo, conviértela en una variante propia, como verás en el módulo 15.


14. Pseudoelementos

Un pseudoelemento es una parte de un elemento que CSS te deja tratar como si existiera en el HTML.

before y after

<label class="block font-medium after:ml-0.5 after:text-red-600 after:content-['*']">Nombre</label>

<span class="relative pl-4 text-sm before:absolute before:top-1.5 before:left-0 before:size-2
             before:rounded-full before:bg-green-500">En stock</span>

<blockquote class="relative pl-6 text-slate-600 italic
                   before:absolute before:inset-y-0 before:left-0 before:w-1 before:rounded-full before:bg-indigo-400">
  El pedido llegó en dos días.
</blockquote>

En CSS puro, ::before no se pinta sin una declaración content. Tailwind la añade por ti con cada clase before: o after:, y por eso ninguna de las decoraciones de arriba escribe before:content-['']:

.before\:size-2::before {
  content: var(--tw-content);
  width: calc(var(--spacing) * 2);
  height: calc(var(--spacing) * 2);
}

--tw-content vale la cadena vacía si no lo cambias. Para poner un texto usa content-[...], que le da valor a esa variable:

.after\:content-\[\'\*\'\]::after {
  --tw-content: '*';
  content: var(--tw-content);
}

Tres errores frecuentes:

  • Falta relative. Un before:absolute se coloca respecto al ancestro posicionado más cercano. Sin relative en el elemento, puede acabar en cualquier parte.
  • Los elementos sustituidos no tienen ::before. En <input> e <img> no aparecen. Envuelve el elemento en un <div> o un <span> y ponlos ahí.
  • El contenido no se lee ni se selecciona de forma fiable. Un texto importante debe ir en el HTML, no en content. Los pseudoelementos son para decoración. El asterisco de obligatorio sirve de marca visual, pero el campo debe llevar además el atributo required.

Hover sobre un pseudoelemento

El orden importa: primero el estado del elemento y al final el pseudoelemento.

<a href="#" class="relative font-medium text-indigo-700
                   after:absolute after:-bottom-0.5 after:left-0 after:h-0.5 after:w-0 after:bg-indigo-700
                   hover:after:w-full">
  Ver todas las ofertas
</a>

Se ha comprobado con dos elementos: con hover:before:bg-blue-500, el ::before se pinta de azul al pasar el puntero; con before:hover:bg-red-500, la versión 4 genera el hover sobre el propio pseudoelemento y no se pinta nada. En 3.4 las dos formas dan lo mismo, porque esa versión siempre coloca el pseudoelemento al final, así que este error aparece al migrar.

El resto de pseudoelementos

Variante CSS Para qué
placeholder: ::placeholder Texto de ejemplo de un campo
file: ::file-selector-button Botón de <input type="file">
marker: ::marker Viñetas de una lista
selection: ::selection Texto seleccionado
first-letter: ::first-letter Letra inicial
first-line: ::first-line Primera línea
backdrop: ::backdrop Fondo de un <dialog> o popover
details-content: ::details-content Contenido de un <details> (🆕 v4.1, ver sección 16)
<ul class="list-disc pl-5 marker:text-indigo-600">
  <li>Garantía de 3 años</li>
  <li>Devolución gratuita</li>
</ul>

<input type="file" class="text-sm text-slate-500 file:mr-3 file:rounded-lg file:border-0 file:bg-indigo-50
                          file:px-3 file:py-1.5 file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">

<input placeholder="Buscar productos"
       class="w-full rounded-sm border border-slate-300 px-2 py-1 placeholder:text-slate-400 placeholder:italic">

<p class="text-sm first-letter:float-left first-letter:mr-1.5 first-letter:text-3xl first-letter:font-bold
          first-letter:text-indigo-700 first-line:font-medium">
  Nuestro portátil más ligero pesa menos de un kilo y aguanta una jornada entera sin cargador.
</p>

Ejemplos de asterisco de obligatorio, punto de estado, subrayado, barra en una cita, viñetas de color, botón de archivo, placeholder en cursiva y letra capitular

Figura 13. Los pseudoelementos cubren los detalles que antes obligaban a escribir CSS aparte.

marker: y selection: tienen algo especial: se aplican al elemento y también a sus descendientes.

.selection\:bg-orange-200 ::selection { background-color: var(--color-orange-200); }
.selection\:bg-orange-200::selection { background-color: var(--color-orange-200); }

Así puedes poner selection: una sola vez en un contenedor, o en <body>, y afecta a toda la página.

Un párrafo seleccionado con fondo naranja claro

Figura 14. selection: puesto en el contenedor, no en el párrafo seleccionado.


15. Atributos como estado: aria-* y data-*

Muchos componentes guardan su estado en atributos. Es una costumbre excelente: JavaScript cambia un atributo y todo el aspecto se deriva de él, sin añadir ni quitar listas de clases.

aria-*

Los atributos ARIA informan a los lectores de pantalla. Si ya tienes que mantenerlos, puedes reutilizarlos para el estilo, y además evitas que aspecto y accesibilidad se desincronicen:

<div role="tablist" class="flex gap-1 border-b border-slate-200">
  <button role="tab" aria-selected="true"
          class="border-b-2 border-transparent px-3 py-2 text-slate-500
                 aria-selected:border-indigo-600 aria-selected:text-indigo-700">Descripción</button>
  <button role="tab" aria-selected="false"
          class="border-b-2 border-transparent px-3 py-2 text-slate-500
                 aria-selected:border-indigo-600 aria-selected:text-indigo-700">Opiniones</button>
</div>
.aria-selected\:text-indigo-700[aria-selected="true"] {
  color: var(--color-indigo-700);
}

Compara siempre con "true". Por eso aria-expanded: no se activa con aria-expanded="false", y lo correcto es dejar el atributo a "false", no quitarlo.

Para atributos con otros valores, usa corchetes:

<th aria-sort="ascending"
    class="after:ml-1 aria-[sort=ascending]:text-indigo-700
           aria-[sort=ascending]:after:content-['▲'] aria-[sort=descending]:after:content-['▼']">
  Producto
</th>

<a href="#" aria-current="page" class="aria-[current=page]:font-bold aria-[current=page]:text-indigo-700">Catálogo</a>

También existen group-aria-* y peer-aria-*, y sirven por ejemplo para girar una flecha:

<button aria-expanded="true" class="group flex items-center justify-between rounded-lg border border-slate-300 px-3 py-2
                                    aria-expanded:border-indigo-600">
  Filtros <span class="group-aria-expanded:rotate-180">▾</span>
</button>

Atributos ARIA sin nombre propio 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) solo tienen variante aria-busy, aria-checked, aria-disabled, aria-expanded, aria-hidden, aria-pressed, aria-readonly, aria-required y aria-selected; v4 acepta cualquiera. Si usas la LTS: para los demás usa corchetes: aria-[invalid=true]:border-red-500 en lugar de aria-invalid:border-red-500.

Ojo con que v4 admita cualquier nombre: aria-current: compara con "true", y por tanto no coincide con aria-current="page". Para ese valor necesitas aria-[current=page]:.

data-*

Los atributos data-* son los tuyos: puedes guardar lo que quieras. Con un valor por atributo se representan muy bien los estados con varias opciones, como el estado de un pedido:

<span data-estado="ENVIADO"
      class="rounded-full bg-slate-100 px-2.5 py-0.5
             data-[estado=CANCELADO]:bg-red-100 data-[estado=CANCELADO]:text-red-700
             data-[estado=ENTREGADO]:bg-green-100 data-[estado=ENTREGADO]:text-green-700
             data-[estado=ENVIADO]:bg-sky-100 data-[estado=ENVIADO]:text-sky-700">
  Enviado
</span>

La misma cadena de clases sirve para los cuatro estados. Si el valor llega de la API, basta con volcarlo en el atributo: no hace falta una función que traduzca cada estado a sus clases.

Pestañas con Descripción activa, botones desplegables con la flecha girada, tabla con flecha de orden, navegación con la página actual y cuatro etiquetas de estado

Figura 15. Todos estos aspectos salen de atributos: cambiar el estado es cambiar uno de ellos.

data-* sin corchetes 🔄 v4.0

🔄 Cambia en Tailwind 4.0. La forma data-activo: no existe en la LTS (Tailwind 3.4), donde siempre hacen falta corchetes. Si usas la LTS: escribe data-[activo]:, que funciona igual en las dos versiones. Lo mismo vale para group-data-[abierto]:.

En v4, data-activo: comprueba si el atributo existe, generando [data-activo]. Aquí está la trampa:

<span data-activo="false" class="data-activo:text-red-600">Se pone rojo</span>
<span data-activo="false" class="data-[activo=true]:text-red-600">No se pone rojo</span>

⚠️ Importante: data-activo: y aria-expanded: se parecen, pero no funcionan igual. aria-* compara con "true", y data-* sin valor solo mira si el atributo existe. Con data-activo="false" el atributo existe, así que la clase se aplica. Si tu componente escribe "true" y "false", usa data-[activo=true]:.


16. open, popover e inert

open 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) open: solo genera [open], de modo que no reacciona a popover. Si usas la LTS: para un popover usa la variante arbitraria [&:popover-open]:. En <details> y <dialog> funciona igual.

open: reacciona a los elementos que se abren y se cierran sin JavaScript:

<details class="group rounded-lg border border-slate-200 open:border-indigo-400 open:bg-indigo-50/50">
  <summary class="flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium">
    ¿Cuánto tarda el envío?
    <span class="group-open:rotate-180">▾</span>
  </summary>
  <p class="px-3 pb-3 text-sm text-slate-600">Entre 3 y 5 días laborables.</p>
</details>

Acordeón con una pregunta abierta y resaltada, con la flecha girada, y otra cerrada

Figura 16. open: sobre el propio <details> y group-open: sobre la flecha que está dentro.

Una lista de <details> con el mismo atributo name se comporta como un acordeón: al abrir uno se cierra el anterior. Eso es HTML, no Tailwind.

/* Tailwind 4.3 */
.open\:flex:is([open], :popover-open, :open) { display: flex; }

/* Tailwind 3.4 */
.open\:flex[open] { display: flex; }

Para un popover:

<button popovertarget="ayuda" class="rounded-lg border border-slate-300 px-3 py-1.5">Ayuda</button>
<div id="ayuda" popover class="m-auto rounded-xl border border-slate-200 p-4 shadow-lg
                               open:outline-2 open:outline-indigo-400">
  Llámanos al 900 100 100.
</div>

Página con un mensaje de ayuda flotando en el centro, sobre el resto del contenido

Figura 17. El popover abierto queda por encima de todo sin usar z-index.

⚠️ Importante: Fíjate en m-auto. Los navegadores centran un popover con margin: auto, pero Preflight pone los márgenes a cero. Sin m-auto, aparece pegado a la esquina superior izquierda: se ha medido left: 0. El <dialog>, que verás en el módulo 17, se centra del mismo modo. Es un efecto del Preflight de v4: el de 3.4 no pone a cero los márgenes de todos los elementos, así que allí m-auto no hace falta, pero tampoco estorba.

details-content 🆕 v4.1

🆕 Desde Tailwind 4.1. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa la variante arbitraria [&::details-content]:, o envuelve el contenido en un <div> y dale estilo a él, que es lo que mejor funciona en navegadores antiguos.

Da estilo al contenedor interno del contenido de un <details>, sin añadir un elemento:

<details open class="details-content:border-l-4 details-content:border-indigo-400 details-content:pl-3">
  <summary class="font-medium">Más datos</summary>
  <p class="text-sm">Garantía de 3 años.</p>
</details>

La utilidad se aplica al pseudoelemento ::details-content, y getComputedStyle da 4px de borde en Chromium.

inert 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: usa [&[inert]]:opacity-50, teniendo en cuenta que solo afecta al propio elemento: en v4, inert: afecta también a sus descendientes.

El atributo inert deja una zona sin interacción: no se puede pulsar ni enfocar. Pero no cambia su aspecto, y por eso conviene indicarlo:

<div inert class="inert:opacity-50">
  <button class="rounded-lg border border-slate-300 px-3 py-1.5">No se puede pulsar</button>
</div>

17. Orden de apilado 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) las variantes apiladas se aplican de derecha a izquierda, y en v4 de izquierda a derecha. Si usas la LTS: invierte el orden en las cadenas que incluyan *:, **: o variantes arbitrarias de descendiente: *:first: de v4 se escribe first:*: en 3.4.

En la sección 2 viste que en las consultas el orden da igual. Pero *: cambia el sujeto del selector: la regla deja de apuntar al elemento y pasa a apuntar a sus hijos. Las condiciones que vienen después se aplican entonces a otro elemento, y qué se entiende por “después” depende de la versión.

La clase *:first:font-bold leída de derecha a izquierda en Tailwind 3.4 y de izquierda a derecha en Tailwind 4

Figura 18. La misma clase apunta a elementos distintos.

/* *:first:font-bold en Tailwind 3.4 */
.\*\:first\:font-bold:first-child > * { ... }

/* *:first:font-bold en Tailwind 4.3 */
:is(.\*\:first\:font-bold > *):first-child { ... }

En v4 se lee como se escribe: “de los hijos, el primero”. Esta es la prueba real, con el mismo HTML compilado con cada versión:

<div>
  <ul class="*:first:font-bold *:first:text-indigo-600">
    <li>Portátiles</li><li>Monitores</li><li>Periféricos</li>
  </ul>
</div>
<div>
  <ul class="first:*:font-bold first:*:text-indigo-600">
    <li>Portátiles</li><li>Monitores</li><li>Periféricos</li>
  </ul>
</div>

Con Tailwind 3.4, la primera lista tiene todos los elementos resaltados y la segunda solo el primero

Figura 19. Compilado con Tailwind 3.4.19.

Con Tailwind 4.3, la primera lista tiene solo el primer elemento resaltado y la segunda todos

Figura 20. Compilado con Tailwind 4.3.3: exactamente al revés.

Ocurre lo mismo con hover:

Clase Tailwind 3.4 Tailwind 4.3
*:hover:flex .clase:hover > *: al pasar por el padre, todos los hijos :is(.clase > *):hover: el hijo que tiene el puntero
hover:*:flex .clase > *:hover: el hijo que tiene el puntero :is(.clase:hover > *): al pasar por el padre, todos los hijos

En v4 hay una regla sencilla: escribe las condiciones en el orden en que recorres el DOM. Cuando first: va antes de *:, afecta al padre; cuando va después, al hijo. En cambio, entre variantes del mismo elemento, como hover:focus:, el orden solo cambia el texto del selector: el resultado es el mismo.

Si migras un proyecto, este es uno de los cambios que no dan error: la clase sigue existiendo y simplemente apunta a otro sitio. Lo retomamos en el módulo 19.


18. Tipo de puntero y otras variantes de entorno

pointer-* y any-pointer-* 🆕 v4.1

🆕 Desde Tailwind 4.1. No existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe la consulta como variante arbitraria: [@media(pointer:coarse)]:py-3 en lugar de pointer-coarse:py-3, y [@media(any-pointer:coarse)]: para any-pointer-coarse:.

La anchura de la pantalla no dice cómo se usa el dispositivo. Una tableta con teclado y una portátil táctil tienen anchuras parecidas, y se manejan de forma muy distinta. Estas variantes preguntan por el puntero:

Variante CSS Significado
pointer-fine: @media (pointer: fine) El puntero principal es preciso: ratón
pointer-coarse: @media (pointer: coarse) El principal es impreciso: dedo
pointer-none: @media (pointer: none) No hay puntero principal
any-pointer-fine: @media (any-pointer: fine) Alguno de los punteros es preciso
any-pointer-coarse: @media (any-pointer: coarse) Alguno de los punteros es impreciso
any-pointer-none: @media (any-pointer: none) No hay ningún puntero

Esto resuelve el problema de la sección 4. Una lista muestra una acción solo al pasar el puntero, lo que en un móvil dejaría esa acción oculta para siempre:

<li class="group flex items-center justify-between border-b border-slate-200 px-3 py-1 pointer-coarse:py-3">
  <span>Pedido 5001</span>
  <button class="rounded-sm bg-indigo-600 px-2 py-0.5 text-sm text-white opacity-0
                 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100">
    Ver
  </button>
</li>

Cada clase cumple una función:

  • opacity-0: oculto de partida.
  • group-hover:opacity-100: visible con el ratón sobre la fila.
  • group-focus-within:opacity-100: visible con el teclado, para que no se quede invisible un botón enfocado.
  • pointer-coarse:opacity-100: siempre visible en táctil.
  • pointer-coarse:py-3: filas más altas, para acertar con el dedo.

Lista de pedidos con filas bajas y sin botones visibles

Figura 21. Con ratón: filas compactas y acciones ocultas hasta que pases por encima.

La misma lista con filas más altas y un botón Ver siempre visible

Figura 22. El mismo HTML con emulación táctil: filas de 12px de relleno y opacidad 1. Una copia que solo llevaba group-hover:opacity-100 se quedó con opacidad 0.

💡 Consejo: Para algo imprescindible, no lo escondas en absoluto. El hover es una ayuda para reducir ruido visual, y no debería ser la única forma de llegar a una función.

Otras variantes de entorno

Variante CSS Nota
print: @media print Estilos de impresión
portrait: / landscape: @media (orientation: ...) Orientación
motion-safe: / motion-reduce: @media (prefers-reduced-motion: ...) Módulo 13
contrast-more: / contrast-less: @media (prefers-contrast: ...) Contraste
forced-colors: @media (forced-colors: active) Modo de alto contraste
rtl: / ltr: Dirección del texto En v4 incluye :dir()
supports-[...]: @supports (...) Soporte de una propiedad
dark: prefers-color-scheme Módulo 12
starting: @starting-style 🆕 v4.0, módulo 13
<div class="print:hidden">No se imprime</div>
<aside class="flex supports-[display:grid]:grid">...</aside>

noscript e inverted-colors 🆕 v4.1

🆕 Desde Tailwind 4.1. No existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe [@media(scripting:none)]: para noscript: y [@media(inverted-colors:inverted)]: para inverted-colors:.

noscript: corresponde a @media (scripting: none) y aplica estilos cuando JavaScript no está disponible, lo que permite mostrar un aviso sin la etiqueta <noscript>. inverted-colors: corresponde a @media (inverted-colors: inverted): el usuario ha invertido los colores del sistema, y quizá quieras restablecer las imágenes.


19. Caso práctico: panel de pedidos 🆕 v4.1

🆕 Desde Tailwind 4.1. Este ejemplo usa pointer-coarse:, que no existe en la LTS (Tailwind 3.4). Si usas la LTS: sustitúyelo por [@media(pointer:coarse)]:opacity-100. El resto de clases funciona en las dos versiones.

Este ejemplo junta buena parte del módulo. Es la tabla de pedidos del panel de control de la tienda:

<div class="max-w-3xl overflow-hidden rounded-xl border border-slate-200 bg-white">
  <table class="w-full text-sm">
    <thead class="bg-slate-50 text-left text-slate-500">
      <tr>
        <th class="w-8 py-2 pl-3"></th>
        <th class="px-2">Pedido</th>
        <th class="px-2">Cliente</th>
        <th class="px-2 text-right">Total</th>
        <th class="px-2">Estado</th>
        <th></th>
      </tr>
    </thead>
    <tbody>
      <tr class="group border-b border-slate-200 last:border-b-0 hover:bg-slate-50 has-[:checked]:bg-indigo-50">
        <td class="py-2 pl-3">
          <input type="checkbox" aria-label="Seleccionar pedido 5020" class="accent-indigo-600">
        </td>
        <td class="px-2 font-medium">5020</td>
        <td class="px-2">Hotel Mar Azul S.L.</td>
        <td class="px-2 text-right">1.663,64 €</td>
        <td class="px-2">
          <span data-estado="ENVIADO"
                class="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs
                       data-[estado=CANCELADO]:bg-red-100 data-[estado=CANCELADO]:text-red-700
                       data-[estado=ENTREGADO]:bg-green-100 data-[estado=ENTREGADO]:text-green-700
                       data-[estado=ENVIADO]:bg-sky-100 data-[estado=ENVIADO]:text-sky-700">enviado</span>
        </td>
        <td class="pr-3 text-right">
          <button class="rounded-sm px-2 py-0.5 text-sm text-indigo-700 opacity-0
                         group-focus-within:opacity-100 group-hover:opacity-100
                         hover:bg-indigo-100 pointer-coarse:opacity-100">Detalles</button>
        </td>
      </tr>
      <!-- ...más filas... -->
    </tbody>
  </table>
</div>

Tabla de pedidos con las filas marcadas en azul claro, etiquetas de estado de colores y el botón Detalles visible solo en la fila con el puntero encima

Figura 23. Todo el comportamiento visual está en las clases. La fila 5020 tiene el puntero encima, y las marcadas se resaltan con has-[:checked].

Lo que ves Quién lo hace
Sin borde en la última fila last:border-b-0
Fila resaltada al pasar hover:bg-slate-50
Fila seleccionada has-[:checked]:bg-indigo-50 en la <tr>
Color de la etiqueta data-[estado=...]: con el valor de la API
Botón “Detalles” group en la fila, group-hover: y group-focus-within:
Botón en táctil pointer-coarse:opacity-100

Fíjate en lo que no hay: ningún JavaScript que ponga una clase al marcar una fila. La casilla es la fuente de la verdad, y CSS deriva el aspecto.


20. Depurar una variante que no funciona

Cuando una variante “no hace nada”, sigue este orden:

  1. ¿Se ha generado la clase? Busca el nombre en la hoja de estilos desde las herramientas de desarrollo. Si no aparece, Tailwind no la vio, o el nombre es incorrecto: una variante que no existe no da ningún error. Puedes repasar la detección en el módulo 01.
  2. ¿Se cumple la condición? Selecciona el elemento y mira en el panel Styles si aparece la regla. Si no, usa :hov para forzar el estado.
  3. ¿Otra regla le gana? Si la regla aparece tachada, otra posterior la anula. Recuerda el orden de la sección 2 y el caso de *:.
Síntoma Causa probable
hover: no hace nada en el móvil En v4 es lo esperado: necesita (hover: hover)
peer-checked: no reacciona El peer va después del elemento, o no comparten padre
group-focus: no reacciona El foco está en un hijo: usa group-focus-within:
group-hover: se activa con el grupo equivocado Grupos anidados sin nombre: usa group/nombre
*:bg-... no se deja sobrescribir Limitación de *:: mueve las clases a los hijos
before: no aparece Elemento sustituido (input, img), o falta relative
in-hover: se activa siempre <html> y <body> también están en hover
data-activo: se activa con "false" Solo comprueba si el atributo existe
*:first: selecciona otra cosa tras migrar El orden de apilado se invirtió
before:hover: no hace nada en v4 Escribe hover:before:
first: no se aplica Hay otro elemento antes: usa first-of-type:
disabled: no actúa en un enlace <a> no admite disabled: usa aria-disabled:
empty: no oculta el elemento Tiene un espacio o un salto de línea dentro

21. Ejercicios

Todas las soluciones se han probado en Chromium con Tailwind 4.3.3, comprobando el estilo calculado en cada estado.

Ejercicio 1: Botón secundario con todos sus estados

Crea un botón de borde indigo y fondo transparente, con el texto “Guardar borrador”. Debe tener hover y pulsación con fondo suave, indicador de foco solo para teclado, y estado desactivado gris. El cursor de mano solo debe aparecer si se puede pulsar.

Ver solución
<button class="rounded-lg border border-indigo-600 px-4 py-2 font-medium text-indigo-700
               enabled:cursor-pointer
               hover:bg-indigo-50
               focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600
               active:bg-indigo-100
               disabled:border-slate-300 disabled:text-slate-400">
  Guardar borrador
</button>

hover:bg-indigo-50 se seguiría aplicando a un botón desactivado, porque aquí no hay un disabled:bg-* que lo anule. Si quieres evitarlo, añade disabled:bg-transparent, o en v4 escribe not-disabled:hover:bg-indigo-50.

Ejercicio 2: Lista de la compra

Crea una lista de tres productos con precio, con fondo alterno en las filas pares y separador entre filas, pero sin separador en la última.

Ver solución
<ul class="w-72 rounded-lg border border-slate-200 text-sm">
  <li class="flex justify-between border-b border-slate-200 px-3 py-2 last:border-b-0 even:bg-slate-50">
    <span>Portátil UltraBook 14</span><span>899 €</span>
  </li>
  <li class="flex justify-between border-b border-slate-200 px-3 py-2 last:border-b-0 even:bg-slate-50">
    <span>Monitor 27 4K</span><span>379 €</span>
  </li>
  <li class="flex justify-between border-b border-slate-200 px-3 py-2 last:border-b-0 even:bg-slate-50">
    <span>Ratón inalámbrico</span><span>29 €</span>
  </li>
</ul>

En el navegador, la última fila da border-bottom-width: 0px. En v4, también podrías escribir not-last:border-b y ahorrarte last:border-b-0.

Ejercicio 3: Tarjeta de empleado con dos grupos

Crea una tarjeta de empleado con un círculo de iniciales y un enlace “Ver →”. Al pasar por la tarjeta, el círculo se pone indigo y aparece el enlace, también si llega el foco del teclado. Al pasar por el enlace, y solo entonces, la flecha se desplaza a la derecha.

Ver solución
<article class="group/tarjeta flex w-72 items-center gap-3 rounded-xl border border-slate-200 p-3 hover:border-indigo-400">
  <span class="flex size-10 items-center justify-center rounded-full bg-slate-200 font-semibold
               group-hover/tarjeta:bg-indigo-600 group-hover/tarjeta:text-white">AG</span>
  <div>
    <h3 class="font-semibold">Ana García</h3>
    <p class="text-sm text-slate-500">Dirección general</p>
  </div>
  <a href="#" class="group/enlace ml-auto text-sm text-indigo-700 opacity-0
                     group-focus-within/tarjeta:opacity-100 group-hover/tarjeta:opacity-100">
    Ver <span class="inline-block group-hover/enlace:translate-x-1">→</span>
  </a>
</article>

El inline-block de la flecha hace falta, porque las transformaciones no afectan a los elementos en línea. Con los dos nombres, el puntero sobre el título hace aparecer el enlace, pero no mueve la flecha, que da translate: none. Sobre el enlace, da 4px.

Ejercicio 4: Casilla personalizada

Crea una casilla de “Acepto las condiciones” que sea un cuadrado propio, con fondo indigo cuando está marcada y el texto en negrita. Debe manejarse con el teclado y mostrar el foco.

Ver solución
<label class="flex items-center gap-2">
  <input type="checkbox" class="peer sr-only">
  <span class="size-5 rounded-sm border-2 border-slate-400
               peer-checked:border-indigo-600 peer-checked:bg-indigo-600
               peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-600"></span>
  <span class="peer-checked:font-semibold">Acepto las condiciones</span>
</label>

Los dos <span> son hermanos posteriores del input, y por eso los dos pueden reaccionar. sr-only lo oculta a la vista, pero sigue siendo enfocable. Con hidden se perdería el teclado.

Ejercicio 5: Etiqueta flotante

Crea un campo “Código postal” cuya etiqueta se vea grande dentro del campo cuando está vacío, y suba y se reduzca al enfocarlo o al tener contenido.

Ver solución
<div class="relative w-72">
  <input id="cp" placeholder=" "
         class="peer w-full rounded-lg border border-slate-300 px-3 pt-5 pb-1.5
                focus:border-indigo-600 focus:outline-hidden">
  <label for="cp"
         class="absolute top-1 left-3 text-xs text-slate-500
                peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-base
                peer-focus:top-1 peer-focus:text-xs peer-focus:text-indigo-600">
    Código postal
  </label>
</div>

La etiqueta mide top: 14px con el campo vacío, y 4px tanto con foco como con el valor 41001. El estilo base es el de “arriba”, y placeholder-shown lo baja. Si lo planteas al revés, no podrías distinguir un campo con contenido. Sin placeholder=" ", la variante no funciona, porque un input sin placeholder nunca cumple :placeholder-shown.

Ejercicio 6: Selector de talla

Crea tres tallas S, M y L como botones de una rejilla, con radios ocultos. La talla marcada se resalta, la L está agotada y no se puede elegir, y el foco del teclado debe verse.

Ver solución
<div class="grid w-96 grid-cols-3 gap-2">
  <label class="flex cursor-pointer justify-center rounded-lg border-2 border-slate-200 py-2 font-medium
                has-[:checked]:border-indigo-600 has-[:checked]:bg-indigo-50 has-[:checked]:text-indigo-700
                has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-40
                has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-indigo-600">
    <input type="radio" name="talla" class="sr-only">S
  </label>
  <label class="...las mismas clases...">
    <input type="radio" name="talla" class="sr-only" checked>M
  </label>
  <label class="...las mismas clases...">
    <input type="radio" name="talla" class="sr-only" disabled>L
  </label>
</div>

Con has-* no hace falta ninguna marca, ni que el radio tenga una posición concreta. Toda la lógica de un radio (uno solo a la vez, flechas del teclado) la da el navegador. Al pulsar S, M pierde el resaltado sin escribir nada más.

Ejercicio 7: Etiqueta de estado

Crea una etiqueta para el estado de un pedido, con la misma cadena de clases para cualquier estado. Por defecto (PENDIENTE) es ámbar. ENVIADO es azul, y CANCELADO es rojo y tachado.

Ver solución
<span data-estado="CANCELADO"
      class="rounded-full bg-amber-100 px-2.5 py-0.5 text-sm text-amber-800
             data-[estado=CANCELADO]:bg-red-100 data-[estado=CANCELADO]:text-red-700 data-[estado=CANCELADO]:line-through
             data-[estado=ENVIADO]:bg-sky-100 data-[estado=ENVIADO]:text-sky-700">
  Cancelado
</span>

Los corchetes son imprescindibles porque se compara un valor. Cambiar el aspecto desde JavaScript se reduce a etiqueta.dataset.estado = 'ENVIADO'.

Ejercicio 8: Campo con error amable 🆕 v4.1

🆕 Desde Tailwind 4.1. Este ejercicio usa user-invalid: y peer-user-invalid:, que no existen en la LTS (Tailwind 3.4). Si usas la LTS: escribe [&:user-invalid]:border-red-500 y peer-[:user-invalid]:block.

Crea un campo de correo obligatorio con su etiqueta marcada con un asterisco. El borde rojo y el mensaje de error solo deben aparecer cuando el usuario haya salido del campo con un valor incorrecto, y deben desaparecer al corregirlo.

Ver solución
<div class="w-72">
  <label for="correo" class="block font-medium after:ml-0.5 after:text-red-600 after:content-['*']">Correo</label>
  <input id="correo" type="email" required
         class="peer w-full rounded-lg border border-slate-300 px-3 py-2
                user-invalid:border-red-500 user-invalid:bg-red-50">
  <p class="mt-1 hidden text-sm text-red-600 peer-user-invalid:block">Escribe un correo válido.</p>
</div>
Paso display del mensaje
Al cargar none
Entrar y salir sin escribir none
Escribir ana y salir block
Corregir a ana@empresa.es none

Observa que al corregir, el mensaje desaparece sin salir del campo: :user-invalid deja de cumplirse en cuanto el valor es correcto.

Ejercicio 9: Filtro sin JavaScript

Crea una lista de productos con una casilla “Solo con stock”. Al marcarla se ocultan los productos agotados, que además se ven en gris. No uses JavaScript.

Ver solución
<section class="group/catalogo w-72">
  <label class="flex items-center gap-2">
    <input id="solo-stock" type="checkbox"> Solo con stock
  </label>
  <ul class="mt-2 text-sm">
    <li class="py-1 data-[agotado]:text-slate-400
               group-has-[#solo-stock:checked]/catalogo:data-[agotado]:hidden">Portátil UltraBook 14</li>
    <li data-agotado class="py-1 data-[agotado]:text-slate-400
               group-has-[#solo-stock:checked]/catalogo:data-[agotado]:hidden">Monitor 27 4K</li>
    <li class="py-1 data-[agotado]:text-slate-400
               group-has-[#solo-stock:checked]/catalogo:data-[agotado]:hidden">Teclado mecánico</li>
  </ul>
</section>

La clase se lee por partes: group-has-[#solo-stock:checked]/catalogo significa “dentro del grupo catalogo, si este contiene #solo-stock marcada”; data-[agotado], “y yo tengo ese atributo”; y hidden es la utilidad. El CSS generado es:

.clase:is(:where(.group\/catalogo):has(:is(#solo-stock:checked)) *)[data-agotado] {
  display: none;
}

Al marcar la casilla, el monitor pasa a display: none, y al desmarcarla vuelve a list-item. Como las variantes aquí se aplican al mismo elemento, funciona igual en las dos versiones.

Ejercicio 10 (propuesto): Preguntas frecuentes

Crea tres <details> con el mismo name, de modo que solo pueda haber uno abierto. Resalta el abierto con open:, gira una flecha con group-open: y da una barra lateral al contenido con details-content:. Prueba qué le pasa al segundo <details> si está abierto en el HTML junto al primero.

Ejercicio 11 (propuesto): Traducir a Tailwind 3.4

Reescribe para 3.4 estas clases de v4, compílalas y comprueba que hacen lo mismo: *:first:font-bold, not-last:border-b, nth-3:bg-indigo-200, user-invalid:bg-red-50, in-data-[seccion=ofertas]:text-orange-600, pointer-coarse:py-3, hover:flex!, data-activo:font-bold.


Resumen

Concepto API Idea clave
Variante hover:, md: Prefijo que traduce a una condición de CSS; genera una regla nueva
Orden entre estados hover, focus, active, disabled Decide la hoja de estilos, no el atributo class
Foco focus-visible:, focus-within: Muestra el foco a quien lo necesita, sin anillos tras un clic
Hover táctil 🔄 v4.0 @media (hover: hover) En un móvil, hover: no existe: nada esencial debe depender de él
Formulario checked:, required:, invalid: Estados sin JavaScript
Validación amable 🆕 v4.1 user-invalid:, user-valid: Espera a que el usuario actúe
Posición first:, last:, odd:, even: Cuenta dentro del padre, sea cual sea la etiqueta
Posición concreta 🆕 v4.0 nth-3:, nth-[3n+1]: En 3.4, [&:nth-child(3)]:
Grupo group, group-hover:, group/nombre El ancestro marcado manda; nombra los grupos anidados
Hermano peer, peer-checked: Solo hermanos posteriores
Contenido has-[:checked]: El contenedor pregunta por lo que tiene dentro
Ancestro 🆕 v4.0 in-data-[x]: Sin marca; evita in-hover:
Negación 🆕 v4.0 not-last:, not-[.x]: Niega selectores y consultas
Hijos *:, **: 🆕 v4.0 El hijo no puede sobrescribirlas sin !
Pseudoelementos before:, after:, marker:, file: content automático; hover:before:, no al revés
Atributos aria-*, data-[x=y]: aria-* compara con "true"; data-x: solo mira si existe
Apertura 🔄 v4.0 open:, group-open: En v4 cubre también popover
Apilado 🔄 v4.0 *:first: En v4 se lee de izquierda a derecha; en 3.4, al revés
Puntero 🆕 v4.1 pointer-coarse:, any-pointer-*: Se adapta a cómo se usa, no a la anchura