Módulo 17: Formularios y tablas

Objetivos del módulo

  • Saber qué deja Preflight en un formulario y por qué un <input> sin clases parece invisible
  • Construir un campo completo sin plugin: etiqueta, ayuda, foco visible y estado desactivado
  • Dar estilo a select, textarea, casillas, botones de opción, input type="file" y input type="range"
  • Crear controles a medida sin perder la accesibilidad: interruptores y tarjetas de opción
  • Instalar @tailwindcss/forms, entender sus dos estrategias y decidir cuándo merece la pena
  • Hacer que un textarea crezca con su contenido con field-sizing-*
  • Validar sin JavaScript con invalid: y user-invalid:, y mostrar mensajes con peer y has-*
  • Marcar los errores que llegan del servidor con aria-invalid:
  • Montar un formulario real con modo oscuro y diseño adaptable
  • Dominar el modelo de las tablas: border-collapse, border-separate, table-fixed y caption-*
  • Pulir una tabla de datos: cebra, números alineados, bordes redondeados, cabecera fija y filas seleccionables
  • Convertir una tabla en tarjetas en el móvil sin perder información
  • Usar <dialog> y popover con open:, backdrop: y starting:, y evitar sus trampas

1. Preparación del laboratorio

Sigues con el proyecto Vite del módulo 01. Este módulo trabaja con HTML puro y solo necesita un paquete nuevo, que instalarás en la sección 6. Hasta entonces, app.css contiene una sola línea:

@import "tailwindcss";

Los datos son los de la tienda EMPRESA: productos, categorías, clientes y pedidos. Si necesitas repasar las etiquetas de formulario o de tabla de HTML, tienes el Curso de HTML y CSS. Las variantes de estado se presentaron en el módulo 11; aquí se aplican a casos reales, sin repetir la teoría.

⚠️ 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. El punto de partida: qué hace Preflight con un formulario

Abre un formulario sin ninguna clase en un proyecto con Tailwind y te llevarás un susto:

<form id="crudo">
  <label for="c1">Nombre</label>
  <input id="c1" type="text" placeholder="Portátil UltraBook 14">
  <label for="c2">Categoría</label>
  <select id="c2"><option>Portátiles</option><option>Monitores</option></select>
  <label for="c3">Descripción</label>
  <textarea id="c3" rows="2"></textarea>
  <button>Guardar</button>
</form>

Formulario sin clases: las etiquetas y los campos van seguidos, sin bordes ni separación, y el botón parece texto normal

Figura 1. Lo que queda tras Preflight: los campos no tienen borde ni relleno, y el botón no parece un botón.

No es un error. Preflight quita la apariencia que cada navegador da a los controles, para que todos partan del mismo sitio. Esto es lo que se ha medido en Chromium sobre un <input type="text"> sin clases:

Propiedad Sin Preflight (navegador) Tailwind 4.3 Tailwind 3.4
border-width 2 px 0 0
padding-top 1 px 0 0
font-size 13,33 px 16 px (el del texto) 16 px (el del texto)
background-color Blanco Transparente Blanco

Las dos primeras filas explican por qué el campo es invisible. La tercera es una buena noticia: Preflight hace que los controles hereden la fuente de la página. La cuarta es una diferencia entre versiones que te afectará en el modo oscuro: en v4 el campo deja ver el fondo que tiene detrás, y en v3 se queda blanco.

Otros dos cambios de Preflight 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el texto de ejemplo es gray-400 y los botones llevan cursor: pointer; en 4.0 el texto de ejemplo es el color actual al 50 % y los botones usan cursor: default. Si usas la LTS: no necesitas cursor-pointer en los botones; en cualquier versión, fija el color con placeholder:text-slate-400 para no depender de este valor.

Por eso en los ejemplos de este módulo verás placeholder:text-slate-400 en los campos y cursor-pointer en los botones: son dos clases que dan el mismo resultado en las dos versiones.


3. Un campo de texto completo, sin plugin

Un campo bien hecho tiene cuatro partes: la etiqueta, el control, el texto de ayuda y un foco visible.

<div id="campo" class="max-w-sm">
  <label for="nombre" class="mb-1 block text-sm font-medium text-slate-800">Nombre del producto</label>
  <input id="nombre" name="nombre" type="text" placeholder="Portátil UltraBook 14"
         aria-describedby="nombre-ayuda"
         class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900
                placeholder:text-slate-400
                focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500">
  <p id="nombre-ayuda" class="mt-1 text-sm text-slate-500">Aparecerá en el catálogo y en las facturas.</p>
</div>

Cada grupo de clases tiene una función:

Clases Para qué
block en la etiqueta Un <label> es en línea. Sin block, mb-1 no separa
for="nombre" e id="nombre" Enlazan etiqueta y campo: un clic en el texto activa el campo
w-full Un <input> tiene un ancho propio y no llena su contenedor
border border-slate-300 Devuelve el borde que quitó Preflight, con color explícito
bg-white Necesario en v4 para que el campo no sea transparente
focus:* Sustituye el contorno del navegador por uno de la marca
aria-describedby Hace que un lector de pantalla lea la ayuda al entrar en el campo

Tres campos iguales: en reposo con borde gris, con foco y un halo añil, y desactivado con fondo gris

Figura 2. El mismo campo en reposo, con el foco y desactivado.

💡 Consejo: Escribe siempre una etiqueta real. El texto de ejemplo no la sustituye: desaparece al escribir y no sirve para saber qué se pedía. Si el diseño no deja sitio para ella, usa <label class="sr-only">, que sigue disponible para los lectores de pantalla.

Quitar el contorno sin quitar el foco 🔄 v4.0

🔄 Cambia en Tailwind 4.0. outline-hidden no existe en la LTS (Tailwind 3.4), y outline-none no significa lo mismo en las dos versiones. Si usas la LTS: escribe focus:outline-none, que en 3.4 hace lo que outline-hidden hace en 4.x. El resto de las clases del campo es igual.

focus:outline-hidden esconde el contorno del navegador, porque ya dibujas otro con ring. Nunca lo quites sin poner algo a cambio: quien usa el teclado se quedaría sin saber dónde está. La diferencia entre las dos clases se explica en el módulo 06.

Usa focus: y no focus-visible: en los campos de texto. Con un botón, un clic del ratón no debe dejar marca y focus-visible: lo consigue. Un campo de texto, en cambio, recibe :focus-visible también con el ratón, porque el usuario necesita saber dónde va a escribir.


4. Los demás controles

select

Un <select> tras Preflight conserva su flecha nativa, así que basta con las mismas clases del campo:

<select id="categoria" class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900
                              focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden">
  <option>Portátiles</option>
  <option>Monitores</option>
  <option>Telefonía</option>
</select>

Si quieres cambiar la flecha, primero hay que quitar la nativa con appearance-none y dibujar otra. Lo más limpio es un envoltorio con un icono encima, que no reciba clics:

<div id="flecha" class="relative max-w-xs">
  <select class="w-full appearance-none rounded-lg border border-slate-300 bg-white py-2 ps-3 pe-9 text-slate-900
                 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden">
    <option>Portátiles</option>
    <option>Monitores</option>
  </select>
  <svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
    <path d="M5 8l5 5 5-5"/>
  </svg>
</div>

pointer-events-none deja pasar el clic al select, y pe-9 evita que el texto largo pase por debajo del icono.

textarea

Preflight le pone resize: vertical, de modo que el usuario solo puede estirarlo hacia abajo. Cámbialo con resize-none, resize-x o resize. Para que crezca solo, consulta la sección 7.

Casillas y botones de opción

Las casillas y los botones de opción conservan su aspecto nativo. Para colorearlos, la clase es accent-*, que ya viste en el módulo 05:

<fieldset id="opciones" class="space-y-2">
  <legend class="text-sm font-medium text-slate-800">Envío</legend>
  <label class="flex items-center gap-2 text-sm text-slate-700">
    <input type="radio" name="envio" checked class="size-4 accent-indigo-600"> Estándar (3 a 5 días)
  </label>
  <label class="flex items-center gap-2 text-sm text-slate-700">
    <input type="radio" name="envio" class="size-4 accent-indigo-600"> Urgente (24 horas)
  </label>
  <label class="flex items-center gap-2 text-sm text-slate-700">
    <input type="checkbox" checked class="size-4 accent-indigo-600"> Envolver para regalo
  </label>
</fieldset>

Un grupo de opciones va dentro de <fieldset> con su <legend>: así un lector de pantalla anuncia la pregunta antes de cada respuesta. Meter el control dentro del <label> ahorra for e id, y amplía la zona donde se puede pulsar.

Elegir un fichero

file: da estilo al botón interno de <input type="file">, que es el pseudoelemento ::file-selector-button:

<input id="fichero" type="file" class="block w-full max-w-sm text-sm text-slate-600
       file:me-3 file:cursor-pointer file:rounded-lg file:border-0 file:bg-indigo-50 file:px-3 file:py-2
       file:text-sm file:font-medium file:text-indigo-700 hover:file:bg-indigo-100">
.file\:bg-indigo-50::file-selector-button {
  background-color: var(--color-indigo-50);
}

Un control deslizante

accent-* también colorea input type="range":

<label id="deslizador" class="block max-w-xs text-sm font-medium text-slate-800">
  Precio máximo
  <input type="range" min="0" max="2000" value="900" class="mt-2 block w-full accent-indigo-600">
</label>

Controles con estilo: flecha propia en un select, botones de opción añiles, botón de fichero con fondo añil claro y control deslizante

Figura 3. Con accent-* y file: los controles nativos se integran en la marca con muy pocas clases.


5. Controles a medida

accent-* no cambia la forma del control. Cuando el diseño pide algo distinto, la técnica es siempre la misma: conserva el <input> real, escóndelo a la vista y dibuja otra cosa según su estado. Así mantienes el teclado, el formulario y los lectores de pantalla sin escribir JavaScript.

Un interruptor

<label id="interruptor" class="inline-flex cursor-pointer items-center gap-3">
  <input type="checkbox" checked class="peer sr-only">
  <span class="relative h-6 w-11 rounded-full bg-slate-300 transition-colors
               peer-checked:bg-indigo-600
               peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2
               after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-white after:transition-transform
               peer-checked:after:translate-x-5"></span>
  <span class="text-sm font-medium text-slate-800">Visible en el catálogo</span>
</label>

Las piezas clave son tres:

  • sr-only esconde la casilla sin quitarla. Con hidden dejaría de recibir el foco.
  • peer y peer-checked: hacen que el <span> reaccione a la casilla, que debe ir antes en el HTML. Lo viste en peer: el hermano manda.
  • peer-focus-visible:ring-2 devuelve el foco visible que la casilla ya no puede mostrar.

Tarjetas de opción 🔄 v4.0

🔄 Cambia en Tailwind 4.0. has-checked: no existe en la LTS (Tailwind 3.4). Si usas la LTS: usa la forma arbitraria has-[:checked]:, que genera el mismo selector y sirve igual en las dos versiones. Lo mismo vale para has-focus-visible:, que pasa a ser has-[:focus-visible]:.

Con peer, el estado solo llega a los hermanos que van detrás. Con has-*, el <label> entero cambia según lo que lleva dentro, y por eso es la forma natural de hacer tarjetas:

<fieldset id="tarjetas" class="grid max-w-lg gap-3 sm:grid-cols-2">
  <legend class="mb-2 text-sm font-medium text-slate-800">Garantía</legend>
  <label class="cursor-pointer rounded-xl border border-slate-300 p-4
                has-checked:border-indigo-600 has-checked:bg-indigo-50
                has-focus-visible:ring-2 has-focus-visible:ring-indigo-300">
    <input type="radio" name="garantia" value="2" checked class="sr-only">
    <span class="block font-semibold text-slate-900">2 años</span>
    <span class="text-sm text-slate-600">Incluida en el precio</span>
  </label>
  <label class="cursor-pointer rounded-xl border border-slate-300 p-4
                has-checked:border-indigo-600 has-checked:bg-indigo-50
                has-focus-visible:ring-2 has-focus-visible:ring-indigo-300">
    <input type="radio" name="garantia" value="4" class="sr-only">
    <span class="block font-semibold text-slate-900">4 años</span>
    <span class="text-sm text-slate-600">Por 49 € más</span>
  </label>
</fieldset>

Interruptor activado en añil junto a dos tarjetas de garantía, con la primera resaltada

Figura 4. Debajo sigue habiendo una casilla y dos botones de opción: se envían con el formulario y funcionan con el teclado.

⚠️ Importante: No uses hidden ni display: none para esconder el control. Dejaría de recibir el foco, y el teclado no podría llegar a él. Por eso la clase es siempre sr-only.


6. El plugin @tailwindcss/forms

Repetir border, px-3 y bg-white en cada campo cansa. El plugin oficial @tailwindcss/forms da un aspecto base razonable a todos los controles sin que escribas ninguna clase. No crea utilidades nuevas: añade reglas de CSS a los selectores de los campos.

Instalación 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En v4 el plugin se carga desde el CSS con @plugin. Si usas la LTS: añádelo en tailwind.config.js, con plugins: [require('@tailwindcss/forms')]. Es el mismo paquete y funciona igual en las dos versiones.

npm install -D @tailwindcss/forms
@import "tailwindcss";
@plugin "@tailwindcss/forms";

En Tailwind 3.4:

// tailwind.config.js
module.exports = {
  content: ['./*.html'],
  plugins: [require('@tailwindcss/forms')],
};

Qué cambia

Compara este HTML, que no lleva ninguna clase en los controles, con y sin el plugin:

<form id="sin-clases" class="grid max-w-sm gap-3">
  <input type="text" placeholder="Nombre">
  <select><option>Portátiles</option><option>Monitores</option></select>
  <textarea rows="2" placeholder="Descripción"></textarea>
  <label class="flex items-center gap-2"><input type="checkbox" checked> Disponible</label>
  <label class="flex items-center gap-2"><input type="radio" checked> Con envío</label>
</form>

Sin plugin, los campos no se distinguen del fondo y las casillas son las nativas

Figura 5. Sin el plugin: solo Preflight.

Con el plugin, los campos tienen borde gris, relleno, flecha propia y casillas cuadradas azules

Figura 6. Con el plugin: el mismo HTML, con aspecto de formulario.

Estos son los valores medidos en un campo de texto:

Propiedad Sin plugin Con el plugin
border 0 1 px, gray-500
padding 0 0.5rem 0.75rem
background-color Transparente #fff
appearance auto none
border-radius 0 0
Foco Contorno del navegador Anillo de 1 px blue-600

Además, el plugin dibuja de nuevo casillas y botones de opción, y sustituye la flecha del select por una imagen SVG.

Las clases mandan: colores y radios

Lo que pone el plugin es solo el punto de partida. Tus utilidades lo sustituyen:

<div id="ajustado" class="grid max-w-sm gap-3">
  <input type="text" placeholder="Nombre" class="rounded-lg border-slate-300 focus:border-indigo-500 focus:ring-indigo-500">
  <label class="flex items-center gap-2"><input type="checkbox" checked class="rounded text-indigo-600 focus:ring-indigo-500"> Disponible</label>
</div>

Fíjate en la casilla: con el plugin, su color se fija con text-*, no con accent-*. El plugin la dibuja con appearance: none y usa currentColor como fondo al marcarla. Se ha medido: el fondo de la casilla marcada es el mismo que da text-indigo-600. accent-* deja de tener efecto, porque ya no queda control nativo que colorear.

Qué gana en el foco 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) el plugin escribe input:where([type='text']):focus, más específico que una utilidad, y el borde pasa a blue-600 aunque hayas puesto border-slate-300. En 4.x el plugin vive en la capa base, y la utilidad gana siempre. Si usas la LTS: añade focus:border-indigo-500 y focus:ring-indigo-500 en cada campo para no ver el azul.

La razón está en cómo se ordena el CSS. Con una capa nativa, lo que hay en utilities gana a lo de base sin importar la especificidad. En v3 esas capas no existen en el navegador, como viste en el módulo 02, y decide la especificidad de cada selector.

Comparación de un campo con foco: en v3 gana la regla del plugin por especificidad, y en v4 gana la utilidad por su capa

Figura 7. La misma clase border-slate-300, medida con el campo enfocado.

Con border-slate-300 y el foco Tailwind 4.3 Tailwind 3.4
Color del borde slate-300 (la utilidad) blue-600 (el plugin)
Anillo 1 px blue-600 1 px blue-600

La consecuencia práctica es que en v4 puedes acabar con un borde gris y un anillo azul a la vez. Por eso focus:border-* y focus:ring-* conviene escribirlos siempre, en cualquier versión.

Estrategia class

Por defecto, el plugin aplica las dos estrategias a la vez: los selectores de elemento y unas clases propias. Si tu proyecto ya tiene estilos y no quieres que el plugin toque todos los campos, limítalo a las clases:

@import "tailwindcss";
@plugin "@tailwindcss/forms" {
  strategy: "class";
}
// tailwind.config.js, en Tailwind 3.4
plugins: [require('@tailwindcss/forms')({ strategy: 'class' })],

Con esta opción, solo cambian los campos que pidan el estilo:

<input type="text" class="form-input rounded-lg">
<select class="form-select rounded-lg"><option>Monitores</option></select>
<input type="checkbox" class="form-checkbox text-indigo-600">

También puedes elegir strategy: "base", que aplica solo los selectores de elemento y no genera form-input. Las clases disponibles son form-input, form-textarea, form-select, form-multiselect, form-checkbox y form-radio.

Medido con strategy: "class": un <input> sin clases se queda sin borde ni relleno, y el que lleva form-input recibe 1 px de borde y 8 px de relleno.

El plugin y el modo oscuro

El plugin escribe background-color: #fff sin más. En un tema oscuro tendrás un campo blanco si no lo cambias:

<input type="text" class="dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100">

¿Plugin sí o no?

Situación Elección
Formularios nuevos con un diseño propio Sin plugin: un componente de campo, como en el módulo 16
Muchos formularios internos que deben verse bien sin esfuerzo Con el plugin, estrategia base
Proyecto existente con estilos propios Con el plugin, estrategia class
Casillas de forma muy distinta a la nativa Con el plugin, o una a medida como la de la sección 5

El resto del módulo no usa el plugin, salvo que se indique.


7. field-sizing-*: el campo que crece con su contenido 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe la propiedad arbitraria [field-sizing:content], que genera el mismo CSS y hemos comprobado en 3.4.19. Otra opción, si el navegador no la admite, es ajustar la altura con JavaScript en el evento input.

Un <textarea> tiene una altura fija, y durante años hizo falta JavaScript para que creciera al escribir. La propiedad CSS field-sizing lo resuelve:

Clase CSS Efecto
field-sizing-fixed field-sizing: fixed El valor por defecto: tamaño fijo
field-sizing-content field-sizing: content El campo mide lo que su contenido
<div id="crece" class="grid max-w-md gap-3">
  <textarea rows="2" class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2">Portátil ligero de 14 pulgadas.
16 GB de RAM y 512 GB de disco.
Pantalla IPS de alto brillo.
Garantía de 3 años.</textarea>
  <textarea rows="2" class="field-sizing-content w-full rounded-lg border border-slate-300 bg-white px-3 py-2">Portátil ligero de 14 pulgadas.
16 GB de RAM y 512 GB de disco.
Pantalla IPS de alto brillo.
Garantía de 3 años.</textarea>
</div>

Dos áreas de texto con el mismo contenido: la de arriba corta el texto en dos líneas, y la de abajo muestra las cuatro

Figura 8. Arriba, tamaño fijo con rows="2" y el texto oculto. Abajo, field-sizing-content.

Tres detalles salen en la práctica:

  • rows deja de tener efecto. Medido: un textarea con rows="2" y una sola línea mide 24 px, no dos líneas. Fija un mínimo con min-h-*.
  • Sin un máximo, el campo crece sin fin. Con max-h-40, al llegar a ese alto aparece la barra de desplazamiento.
  • Sirve también para <input> y <select>. Un <input> con field-sizing-content mide lo que su texto, y hay que darle min-w-* para que no se quede del ancho del cursor.
<textarea class="field-sizing-content min-h-20 max-h-40 w-full rounded-lg border border-slate-300 bg-white px-3 py-2"
          placeholder="Escribe tu comentario"></textarea>

⚠️ Importante: La clase es de v4.0, pero la propiedad depende del navegador, y es muy reciente. Según caniuse, llegó en Chrome y Edge 123, Safari 26.2 y Firefox 152; MDN la da por disponible en todos desde junio de 2026. Donde no exista, el campo se queda con su tamaño de siempre, de modo que nada se rompe. Es lo que se llama una mejora progresiva.


8. Todos los estados de un campo

El módulo 11 presentó estas variantes. Aquí las tienes juntas, con el uso típico de cada una:

Variante CSS Uso típico
focus: :focus Anillo del campo activo
focus-within: :focus-within Resaltar el envoltorio de un campo
disabled: :disabled Campo gris que no se envía
enabled: :enabled Aplicar hover solo si el botón funciona
read-only: :read-only Campo que se envía pero no se edita
required: :required Marcar los obligatorios
optional: :optional Lo contrario
checked: :checked Casillas y opciones marcadas
indeterminate: :indeterminate Casilla “seleccionar todo” a medias
default: :default La opción marcada de partida
placeholder-shown: :placeholder-shown El campo está vacío y muestra el texto de ejemplo
autofill: :autofill Rellenado por el navegador
in-range: :in-range Número dentro de min y max
out-of-range: :out-of-range Número fuera de min y max
invalid: :invalid No cumple sus restricciones (ver sección 9)
user-invalid: :user-invalid Igual, pero tras la interacción (ver sección 9)

disabled y readonly se confunden con frecuencia. Un campo desactivado no recibe el foco y no se envía; uno de solo lectura sí lo hace. Si muestras un total calculado que debe llegar al servidor, usa readonly.

Un error común con los botones: hover:bg-indigo-700 se aplica también a un botón desactivado. enabled:hover:bg-indigo-700 lo evita:

<button disabled class="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
                        enabled:hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50">
  Guardar
</button>

Una etiqueta puede marcar por sí sola los obligatorios: solo hay que escribir required en el campo. La etiqueta pregunta a su grupo si contiene un campo obligatorio, con group-has-required:, y añade el asterisco con after::

<div id="obligatorio" class="group max-w-xs">
  <label for="p-stock" class="mb-1 block text-sm font-medium text-slate-800 group-has-required:after:ms-1 group-has-required:after:text-red-600 group-has-required:after:content-['*']">Stock</label>
  <input id="p-stock" type="number" required class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
</div>

En Tailwind 3.4 no existe group-has-required:: se escribe group-has-[:required]:after:ms-1, y lo mismo con las otras dos clases.


9. Validación sin JavaScript

El navegador ya sabe validar: basta con los atributos required, type="email", min, max, minlength y pattern. Tailwind solo da forma visual a ese resultado.

El problema de invalid:

invalid: activa la pseudoclase :invalid, que se evalúa siempre. Un campo required vacío ya es inválido al cargar la página, de modo que el usuario ve un formulario lleno de errores antes de escribir nada.

user-invalid:, la solución 🆕 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-600, y para los hermanos peer-[:user-invalid]:block. Generan el mismo CSS y están comprobadas en 3.4.19.

<form id="compara" class="grid max-w-md gap-4" onsubmit="event.preventDefault()">
  <label class="block text-sm font-medium text-slate-800">
    Con invalid:
    <input type="email" required value="ana@"
           class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 invalid:border-red-600 invalid:bg-red-50">
  </label>
  <label class="block text-sm font-medium text-slate-800">
    Con user-invalid:
    <input id="correo-usuario" type="email" required value="ana@"
           class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 user-invalid:border-red-600 user-invalid:bg-red-50">
  </label>
</form>

Al cargar, el campo con invalid ya está en rojo y el campo con user-invalid no

Figura 9. Al cargar la página. Los dos tienen el mismo valor incorrecto, pero solo invalid: lo señala.

Tras editar el segundo campo y salir, los dos están en rojo

Figura 10. El mismo formulario después de editar el segundo campo y salir de él.

Línea de tiempo de un campo: invalid se activa desde la carga, user-invalid solo al salir o enviar, y deja de hacerlo al corregir

Figura 11. Cuándo se activa cada variante durante la vida de un campo.

Estos son los casos que se han medido en Chromium, además de los de la tabla del módulo 11:

Situación ¿:user-invalid?
Campo required vacío, al cargar No
Campo required vacío, tras pulsar el botón de enviar Sí
Igual, con novalidate en el formulario Sí

La última fila importa. Con novalidate el navegador no muestra sus mensajes flotantes ni bloquea el envío, pero :user-invalid sigue funcionando. Es la combinación habitual cuando quieres mensajes propios, aunque entonces debes impedir tú el envío con JavaScript, o el formulario saldrá con datos incorrectos.

💡 Consejo: :user-invalid es Baseline desde noviembre de 2023, según MDN. Los navegadores mínimos de Tailwind 4, como Chrome 111, son anteriores a esa fecha, de modo que en ellos el campo simplemente no se pondrá rojo. El envío sigue validándose igual.

Mostrar el mensaje de error

El mensaje debe aparecer al mismo tiempo que el borde rojo. peer-user-invalid: hace que un hermano reaccione al campo:

<form id="mensajes" class="max-w-sm space-y-4" onsubmit="event.preventDefault()">
  <div>
    <label for="correo" class="mb-1 block text-sm font-medium text-slate-800">Correo</label>
    <input id="correo" type="email" required placeholder="ana@empresa.es"
           class="peer w-full rounded-lg border border-slate-300 bg-white px-3 py-2 placeholder:text-slate-400
                  focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                  user-invalid:border-red-600 user-invalid:focus:ring-red-200">
    <p class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block">Escribe un correo válido, como ana@empresa.es.</p>
  </div>
  <div>
    <label for="stock" class="mb-1 block text-sm font-medium text-slate-800">Stock</label>
    <input id="stock" type="number" min="0" max="500" required
           class="peer w-full rounded-lg border border-slate-300 bg-white px-3 py-2
                  focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                  user-invalid:border-red-600 user-invalid:focus:ring-red-200">
    <p class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block">Debe estar entre 0 y 500.</p>
  </div>
  <button class="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-700">Guardar</button>
</form>

Dos campos con borde rojo y su mensaje de error debajo, con el botón Guardar al final

Figura 12. Mensajes propios sin una línea de JavaScript. El borde pasa a rojo y el mensaje aparece a la vez.

Observa el orden en user-invalid:focus:ring-red-200: mientras el usuario corrige un campo con error, el anillo también es rojo. Y fíjate en que el campo lleva peer y el mensaje va después de él.

La misma técnica en Tailwind 3.4, comprobada en 3.4.19, con formas arbitrarias:

<div id="validacion-v3">
  <input id="correo-v3" type="email" required
         class="peer w-full rounded-lg border border-slate-300 bg-white px-3 py-2
                focus:outline-none [&:user-invalid]:border-red-600">
  <p class="mt-1 hidden text-sm text-red-700 peer-[:user-invalid]:block">Escribe un correo válido.</p>
</div>

⚠️ Importante: Este mensaje solo es visual. Un lector de pantalla no lo anuncia al aparecer. Si tu formulario debe ser plenamente accesible, enlaza el mensaje con aria-describedby y añade algo de JavaScript que actualice aria-invalid en el campo.

Reaccionar desde fuera: has-* y group-*

peer solo llega a los hermanos posteriores. Si el aviso va fuera del bloque, usa has-* en un antepasado común:

<form id="resumen" class="group max-w-sm space-y-3 rounded-xl border border-slate-200 p-4 has-user-invalid:border-red-300" onsubmit="event.preventDefault()">
  <p class="hidden rounded-lg bg-red-50 p-2 text-sm text-red-800 group-has-user-invalid:block">Revisa los campos marcados.</p>
  <input type="text" required minlength="3" aria-label="Nombre" placeholder="Nombre"
         class="w-full rounded-lg border border-slate-300 px-3 py-2 user-invalid:border-red-600">
  <button class="w-full cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white group-invalid:opacity-50">Guardar</button>
</form>

Aquí hay dos ideas nuevas:

  • <form> también coincide con :invalid si contiene algún campo inválido. Con group-invalid: el botón queda apagado hasta que el formulario esté completo. Se ha medido una opacidad de 0.5 en las dos versiones.
  • group-has-user-invalid: muestra un resumen encima de los campos, en un sitio al que peer no llega.

Sus equivalentes en 3.4 son has-[:user-invalid]: y group-has-[:user-invalid]:.

💡 Consejo: group-invalid:opacity-50 solo cambia el aspecto. Si además pones disabled en el botón, el usuario no podrá pulsarlo y nunca verá por qué el formulario no se envía. Por eso muchas guías recomiendan dejarlo activo y mostrar los errores al pulsar.

Errores que llegan del servidor 🔄 v4.0

🔄 Cambia en Tailwind 4.0. aria-invalid: no existe en la LTS (Tailwind 3.4). Si usas la LTS: escribe aria-[invalid=true]:border-red-600, que funciona en las dos versiones.

El navegador no puede saber que un correo ya está registrado: eso lo dice el servidor. En ese caso marcas el campo desde el HTML que devuelves, con el atributo estándar:

<div id="servidor" class="max-w-sm">
  <label for="correo-usado" class="mb-1 block text-sm font-medium text-slate-800">Correo</label>
  <input id="correo-usado" type="email" value="compras@talleresmartin.es" aria-invalid="true" aria-describedby="correo-error"
         class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 aria-invalid:border-red-600 aria-invalid:bg-red-50">
  <p id="correo-error" class="mt-1 text-sm text-red-700">Este correo ya está registrado.</p>
</div>
Origen del error Se detecta con Variante
Reglas HTML (required, pattern…), en el navegador :user-invalid user-invalid:
El formulario entero form:invalid group-invalid:
Servidor o JavaScript aria-invalid="true" aria-invalid:

10. Caso completo: alta de producto

Vamos a juntar todo en el formulario de alta de la tienda, con modo oscuro y dos columnas en pantallas anchas. Las clases del campo se repiten mucho. En un proyecto real las extraerías, como explica el módulo 16, pero aquí quedan a la vista.

Para el modo oscuro se usa la preferencia del sistema, que es lo que hace dark: sin configurar nada (módulo 12). scheme-light dark:scheme-dark adapta los elementos nativos, como el calendario o la lista de opciones del select.

<form id="alta" class="mx-auto max-w-2xl scheme-light rounded-2xl border border-slate-200 bg-white p-6 shadow-md
                       dark:scheme-dark dark:border-slate-700 dark:bg-slate-900" onsubmit="event.preventDefault()">
  <h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">Nuevo producto</h2>
  <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">Los campos con asterisco son obligatorios.</p>

  <div class="mt-6 grid gap-5 sm:grid-cols-2">
    <div class="sm:col-span-2">
      <label for="p-nombre" class="mb-1 block text-sm font-medium text-slate-800 dark:text-slate-200">Nombre *</label>
      <input id="p-nombre" required minlength="3" value="Monitor 27 4K"
             class="peer w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900
                    focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                    user-invalid:border-red-600
                    dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-indigo-900">
      <p class="mt-1 hidden text-sm text-red-700 peer-user-invalid:block dark:text-red-400">Debe tener al menos 3 letras.</p>
    </div>

    <div>
      <label for="p-categoria" class="mb-1 block text-sm font-medium text-slate-800 dark:text-slate-200">Categoría *</label>
      <select id="p-categoria" required
              class="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900
                     focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                     dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-indigo-900">
        <option>Monitores</option>
        <option>Portátiles</option>
        <option>Periféricos</option>
      </select>
    </div>

    <div>
      <label for="p-precio" class="mb-1 block text-sm font-medium text-slate-800 dark:text-slate-200">Precio *</label>
      <div class="relative">
        <input id="p-precio" type="number" min="0" step="0.01" required value="329"
               class="peer w-full rounded-lg border border-slate-300 bg-white py-2 ps-3 pe-8 text-end text-slate-900 tabular-nums
                      focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                      user-invalid:border-red-600
                      dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-indigo-900">
        <span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 dark:text-slate-400">€</span>
      </div>
    </div>

    <div class="sm:col-span-2">
      <label for="p-descripcion" class="mb-1 block text-sm font-medium text-slate-800 dark:text-slate-200">Descripción</label>
      <textarea id="p-descripcion"
                class="field-sizing-content min-h-20 max-h-48 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900
                       focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:outline-hidden
                       dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100 dark:focus:ring-indigo-900">Monitor de 27 pulgadas con resolución 4K.</textarea>
    </div>

    <label class="flex items-center gap-3 sm:col-span-2">
      <input type="checkbox" checked class="peer sr-only">
      <span class="relative h-6 w-11 shrink-0 rounded-full bg-slate-300 transition-colors
                   peer-checked:bg-indigo-600 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2
                   after:absolute after:left-0.5 after:top-0.5 after:size-5 after:rounded-full after:bg-white after:transition-transform
                   peer-checked:after:translate-x-5 dark:bg-slate-600 dark:peer-focus-visible:ring-offset-slate-900"></span>
      <span class="text-sm font-medium text-slate-800 dark:text-slate-200">Visible en el catálogo</span>
    </label>
  </div>

  <div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
    <button type="button" class="cursor-pointer rounded-lg border border-slate-300 px-4 py-2 font-medium text-slate-700 hover:bg-slate-50
                                 dark:border-slate-600 dark:text-slate-200 dark:hover:bg-slate-800">Cancelar</button>
    <button class="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white hover:bg-indigo-700">Guardar producto</button>
  </div>
</form>

Formulario de alta de producto en modo claro y a dos columnas

Figura 13. El formulario a 900 px, en modo claro.

El mismo formulario con fondo oscuro, campos gris azulado y texto claro

Figura 14. El mismo HTML con el sistema en modo oscuro.

El formulario a una columna en un móvil, con Guardar encima de Cancelar

Figura 15. A 375 px todo queda en una columna.

Detalles que merece la pena mirar:

  • grid gap-5 sm:grid-cols-2 con sm:col-span-2 en los campos anchos: un formulario se alinea mejor con Grid que con flexbox.
  • flex-col-reverse sm:flex-row: en el móvil, el botón principal queda arriba, al alcance del pulgar, sin cambiar el orden del HTML.
  • text-end tabular-nums en el precio: las cifras se alinean como en una columna contable.
  • pe-8 deja sitio al símbolo €, que va encima con absolute.
  • Con el textarea, min-h-20 sustituye a rows, que field-sizing-content ignora.

11. Tablas: el modelo y sus clases

Tailwind no trae un componente de tabla. Lo que tiene son unas pocas clases para las propiedades propias de las tablas, además de todas las demás utilidades.

Lo que hace Preflight

table {
  text-indent: 0;
  border-color: inherit;
  border-collapse: collapse;
}

Las dos versiones traen estas tres reglas. La más importante es la última: las celdas comparten bordes. Como además Preflight quita los márgenes y los pesos de letra, una tabla sin clases es un bloque de texto sin separación.

border-collapse y border-separate

Clase CSS Efecto
border-collapse border-collapse: collapse Las celdas comparten borde. Valor de Preflight
border-separate border-collapse: separate Cada celda tiene el suyo
border-spacing-2 border-spacing: ... Separación entre celdas (solo con separate)
border-spacing-x-2, border-spacing-y-2 Igual, en un eje Por ejemplo, filas como tarjetas

Con collapse, los bordes de las filas se dibujan. Con separate, una fila no tiene borde visible: solo lo tienen las celdas. Es una regla de CSS y no de Tailwind, pero muerde en este punto:

<table id="separada" class="w-full max-w-md border-separate text-left text-sm">
  <tbody class="divide-y divide-slate-300">
    <tr><td class="p-2">Monitor 24 FHD</td><td class="p-2">139 €</td></tr>
    <tr><td class="p-2">Monitor 27 4K</td><td class="p-2">329 €</td></tr>
  </tbody>
</table>

divide-y pone el borde en las filas: el navegador calcula un border-bottom-width de 1 px, pero no pinta nada. La solución es poner el borde en las celdas:

<table id="separada-bien" class="w-full max-w-md border-separate border-spacing-0 text-left text-sm">
  <tbody>
    <tr><td class="border-b border-slate-300 p-2">Monitor 24 FHD</td><td class="border-b border-slate-300 p-2">139 €</td></tr>
    <tr><td class="p-2">Monitor 27 4K</td><td class="p-2">329 €</td></tr>
  </tbody>
</table>

Dos tablas: la de arriba sin línea entre filas a pesar de divide-y, y la de abajo con una línea

Figura 16. Con border-separate, divide-y no dibuja nada (arriba). El borde debe ir en las celdas (abajo).

table-auto y table-fixed

Clase CSS Ancho de columnas
table-auto table-layout: auto Según el contenido. El navegador mira todas las celdas
table-fixed table-layout: fixed Según la primera fila y los anchos que le pongas

Con table-auto, una descripción larga se queda con casi todo el ancho. table-fixed respeta tu reparto, y es el único modo en el que truncate funciona en una celda:

<table id="fija" class="w-full max-w-lg table-fixed text-left text-sm">
  <thead>
    <tr class="border-b border-slate-300">
      <th scope="col" class="w-2/5 py-2 pe-2 font-semibold">Producto</th>
      <th scope="col" class="py-2 pe-2 font-semibold">Descripción</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td class="truncate py-2 pe-2">Portátil UltraBook 14</td>
      <td class="truncate py-2 pe-2">Portátil ligero de 14 pulgadas, 16 GB RAM y pantalla de alto brillo</td>
    </tr>
  </tbody>
</table>

Si en una tabla automática necesitas lo mismo, pon el texto en un <div class="max-w-48 truncate"> dentro de la celda.

Título: caption-*

<caption> da nombre a una tabla, y los lectores de pantalla lo anuncian al entrar en ella. caption-top lo deja arriba (el valor por defecto) y caption-bottom lo baja, algo habitual en las notas al pie:

<table class="w-full caption-bottom text-sm">
  <caption class="mt-2 text-start text-slate-500">Precios con IVA incluido.</caption>
  <tbody>
    <tr><td class="py-1">Ratón inalámbrico</td><td class="py-1 text-end">19,90 €</td></tr>
  </tbody>
</table>

12. Una tabla de datos pulida

<div id="pedidos" class="overflow-hidden rounded-xl border border-slate-200 bg-white">
  <table class="w-full text-left text-sm">
    <caption class="sr-only">Últimos pedidos de la tienda</caption>
    <thead class="bg-slate-50 text-xs tracking-wide text-slate-500 uppercase">
      <tr>
        <th scope="col" class="px-4 py-3 font-medium">Pedido</th>
        <th scope="col" class="px-4 py-3 font-medium">Cliente</th>
        <th scope="col" class="px-4 py-3 font-medium">Fecha</th>
        <th scope="col" class="px-4 py-3 font-medium">Estado</th>
        <th scope="col" class="px-4 py-3 text-end font-medium">Total</th>
      </tr>
    </thead>
    <tbody class="divide-y divide-slate-200 text-slate-700">
      <tr class="even:bg-slate-50 hover:bg-indigo-50">
        <th scope="row" class="px-4 py-3 font-medium text-slate-900">5024</th>
        <td class="px-4 py-3">Gestoría Delta</td>
        <td class="px-4 py-3 whitespace-nowrap">10/07/2026</td>
        <td class="px-4 py-3"><span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">Pendiente</span></td>
        <td class="px-4 py-3 text-end tabular-nums">1497,00 €</td>
      </tr>
      <tr class="even:bg-slate-50 hover:bg-indigo-50">
        <th scope="row" class="px-4 py-3 font-medium text-slate-900">5023</th>
        <td class="px-4 py-3">Laura Benítez Sanz</td>
        <td class="px-4 py-3 whitespace-nowrap">08/07/2026</td>
        <td class="px-4 py-3"><span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">Pendiente</span></td>
        <td class="px-4 py-3 text-end tabular-nums">418,80 €</td>
      </tr>
      <tr class="even:bg-slate-50 hover:bg-indigo-50">
        <th scope="row" class="px-4 py-3 font-medium text-slate-900">5021</th>
        <td class="px-4 py-3">Talleres Martín S.L.</td>
        <td class="px-4 py-3 whitespace-nowrap">15/06/2026</td>
        <td class="px-4 py-3"><span class="rounded-full bg-sky-100 px-2 py-0.5 text-xs font-medium text-sky-800">Enviado</span></td>
        <td class="px-4 py-3 text-end tabular-nums">2462,05 €</td>
      </tr>
      <tr class="even:bg-slate-50 hover:bg-indigo-50">
        <th scope="row" class="px-4 py-3 font-medium text-slate-900">5019</th>
        <td class="px-4 py-3">Clínica Dental Sonrisa</td>
        <td class="px-4 py-3 whitespace-nowrap">21/04/2026</td>
        <td class="px-4 py-3"><span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-800">Entregado</span></td>
        <td class="px-4 py-3 text-end tabular-nums">1296,00 €</td>
      </tr>
    </tbody>
  </table>
</div>

Tabla de pedidos con esquinas redondeadas, cabecera gris en mayúsculas, filas alternas, etiquetas de estado y totales alineados a la derecha

Figura 17. Una tabla de datos: cada detalle tiene una razón.

Cada decisión responde a un problema concreto:

Clases Razón
overflow-hidden rounded-xl en un envoltorio rounded-* no funciona en una tabla con border-collapse, como viste en el módulo 06
<th scope="col"> y <th scope="row"> Enlazan cada celda con su cabecera para los lectores de pantalla
text-end y tabular-nums en los importes Las cifras tienen el mismo ancho y se pueden comparar de un vistazo
whitespace-nowrap en la fecha Una fecha partida en dos líneas se lee mal
even:bg-slate-50 en <tr> Cebra sin JavaScript. even: cuenta entre los hermanos, y solo hay filas
hover:bg-indigo-50 después de even: Las dos tienen el mismo peso. Tailwind deja hover detrás, y por eso gana en las filas pares

No repitas clases en cada celda

Escribir px-4 py-3 en cada celda da mucho HTML. Una variante arbitraria en la propia tabla lo evita, y funciona en las dos versiones:

<div id="sin-repetir" class="max-w-md overflow-hidden rounded-xl border border-slate-200">
  <table class="w-full text-left text-sm [&_td]:px-4 [&_td]:py-2 [&_th]:px-4 [&_th]:py-2">
    <thead class="bg-slate-50"><tr><th scope="col">Producto</th><th scope="col" class="text-end">Stock</th></tr></thead>
    <tbody class="divide-y divide-slate-200">
      <tr><td>Monitor 27 4K</td><td class="text-end">12</td></tr>
      <tr><td>Mesa Elevable</td><td class="text-end">6</td></tr>
    </tbody>
  </table>
</div>

La variante **:, que es de v4.0, no sirve aquí: **:px-4 llegaría a todos los descendientes, también a las filas y a las etiquetas que haya dentro de las celdas. [&_td]: es más precisa.

Hay una advertencia: una utilidad escrita en la celda no sustituye a estas variantes, porque la variante tiene más especificidad. Si [&_td]:px-4 está en la tabla y px-8 en una celda, sigue mandando px-4 (se ha medido: 16 px). Para una excepción, usa el ! del módulo 02 o prescinde de la variante.


13. Cabecera fija

Una tabla larga necesita tener la cabecera a la vista. Ya sabes hacerlo con sticky, del módulo 07, pero hay una trampa que no se ve hasta que te desplazas: con border-collapse, el borde de la cabecera no se pega con ella. Se queda en la fila y se va con el contenido.

La solución es border-separate border-spacing-0. Al no compartir bordes, cada celda se lleva el suyo:

<div id="cabecera-fija" class="h-56 overflow-auto rounded-xl border border-slate-200">
  <table class="w-full border-separate border-spacing-0 text-left text-sm">
    <thead>
      <tr>
        <th scope="col" class="sticky top-0 border-b-2 border-indigo-500 bg-white px-4 py-2">Producto</th>
        <th scope="col" class="sticky top-0 border-b-2 border-indigo-500 bg-white px-4 py-2 text-end">Precio</th>
        <th scope="col" class="sticky top-0 border-b-2 border-indigo-500 bg-white px-4 py-2 text-end">Stock</th>
      </tr>
    </thead>
    <tbody class="[&_td]:border-b [&_td]:border-slate-200 [&_td]:px-4 [&_td]:py-2">
      <tr><td>Portátil UltraBook 14</td><td class="text-end tabular-nums">899,00 €</td><td class="text-end tabular-nums">25</td></tr>
      <tr><td>Portátil Gaming 16</td><td class="text-end tabular-nums">1499,00 €</td><td class="text-end tabular-nums">10</td></tr>
      <tr><td>Portátil Básico 15</td><td class="text-end tabular-nums">499,00 €</td><td class="text-end tabular-nums">40</td></tr>
      <tr><td>Torre Oficina i5</td><td class="text-end tabular-nums">649,00 €</td><td class="text-end tabular-nums">15</td></tr>
      <tr><td>Torre Workstation</td><td class="text-end tabular-nums">1899,00 €</td><td class="text-end tabular-nums">4</td></tr>
      <tr><td>Ratón inalámbrico</td><td class="text-end tabular-nums">19,90 €</td><td class="text-end tabular-nums">200</td></tr>
      <tr><td>Teclado mecánico</td><td class="text-end tabular-nums">79,90 €</td><td class="text-end tabular-nums">60</td></tr>
      <tr><td>Pack teclado y ratón</td><td class="text-end tabular-nums">34,50 €</td><td class="text-end tabular-nums">0</td></tr>
      <tr><td>Monitor 24 FHD</td><td class="text-end tabular-nums">139,00 €</td><td class="text-end tabular-nums">35</td></tr>
      <tr><td>Monitor 27 4K</td><td class="text-end tabular-nums">329,00 €</td><td class="text-end tabular-nums">12</td></tr>
    </tbody>
  </table>
</div>

Dos tablas desplazadas: en la de la izquierda la cabecera perdió su línea añil; en la de la derecha la conserva

Figura 18. Las dos desplazadas 90 px. Con border-collapse (izquierda) el borde no acompaña a la cabecera. Con border-separate border-spacing-0 (derecha), sí.

Otros tres detalles:

  • sticky top-0 va en las celdas <th> y no en <thead> o <tr>. Es lo que funciona de forma más fiable entre navegadores.
  • bg-white es obligatorio. Sin fondo, las filas se verían a través de la cabecera.
  • El contenedor con overflow-auto necesita una altura (aquí h-56). Sin ella, el que se desplaza es la página y sticky no tiene de dónde pegarse dentro del envoltorio.

14. Seleccionar y ordenar filas

Selección con has-*

Esta tabla resalta la fila marcada y muestra una barra de acciones cuando hay algo seleccionado, sin JavaScript. Es la misma técnica de has-* de las tarjetas de opción:

<div id="seleccion" class="group/lista max-w-xl">
  <div class="mb-2 hidden items-center justify-between rounded-lg bg-indigo-600 px-4 py-2 text-sm text-white group-has-checked/lista:flex">
    <span>Productos seleccionados</span>
    <button class="cursor-pointer rounded-md bg-white/20 px-2 py-1 font-medium hover:bg-white/30">Descatalogar</button>
  </div>
  <div class="overflow-hidden rounded-xl border border-slate-200">
    <table class="w-full text-left text-sm">
      <thead class="bg-slate-50 text-slate-500">
        <tr>
          <th scope="col" class="w-10 px-4 py-2"><span class="sr-only">Seleccionar</span></th>
          <th scope="col" class="px-4 py-2 font-medium">Producto</th>
          <th scope="col" class="px-4 py-2 text-end font-medium">Stock</th>
        </tr>
      </thead>
      <tbody class="divide-y divide-slate-200">
        <tr class="has-checked:bg-indigo-50">
          <td class="px-4 py-2"><input type="checkbox" aria-label="Seleccionar Altavoz Inteligente" checked class="size-4 accent-indigo-600"></td>
          <td class="px-4 py-2">Altavoz Inteligente</td>
          <td class="px-4 py-2 text-end tabular-nums">0</td>
        </tr>
        <tr class="has-checked:bg-indigo-50">
          <td class="px-4 py-2"><input type="checkbox" aria-label="Seleccionar Webcam HD" class="size-4 accent-indigo-600"></td>
          <td class="px-4 py-2">Webcam HD</td>
          <td class="px-4 py-2 text-end tabular-nums">30</td>
        </tr>
        <tr class="has-checked:bg-indigo-50">
          <td class="px-4 py-2"><input type="checkbox" aria-label="Seleccionar Pack teclado y ratón" checked class="size-4 accent-indigo-600"></td>
          <td class="px-4 py-2">Pack teclado y ratón</td>
          <td class="px-4 py-2 text-end tabular-nums">0</td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Tabla con dos filas marcadas y resaltadas en añil claro, y una barra azul encima con el botón Descatalogar

Figura 19. La barra aparece cuando hay alguna casilla marcada.

group/lista da un nombre al grupo, y group-has-checked/lista:flex pregunta si ese grupo contiene algo marcado. Una casilla de cada fila lleva su aria-label propio, porque “Seleccionar”, repetido, no diría a qué fila corresponde. En 3.4, las clases son has-[:checked]:bg-indigo-50 y group-has-[:checked]/lista:flex.

La casilla de “seleccionar todo” sí requiere JavaScript. Su estado intermedio, que se pinta con indeterminate:, solo se puede fijar desde código, con casilla.indeterminate = true.

Ordenación con aria-sort

Tailwind no ordena nada, pero sí puede pintar el estado. El atributo estándar aria-sort informa a los lectores de pantalla, y las clases se cuelgan de él. Con aria-sort en el <th> y group para que la flecha lo vea:

<table id="ordenar" class="w-full max-w-md text-left text-sm">
  <thead class="border-b border-slate-300">
    <tr>
      <th scope="col" class="group py-2 font-medium" aria-sort="none">
        <button class="inline-flex cursor-pointer items-center gap-1 text-slate-600 group-aria-[sort=ascending]:text-indigo-700 group-aria-[sort=descending]:text-indigo-700">
          Producto
          <span class="opacity-0 group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:rotate-180 group-aria-[sort=descending]:opacity-100">▲</span>
        </button>
      </th>
      <th scope="col" class="group py-2 text-end font-medium" aria-sort="descending">
        <button class="inline-flex cursor-pointer items-center gap-1 text-slate-600 group-aria-[sort=ascending]:text-indigo-700 group-aria-[sort=descending]:text-indigo-700">
          Precio
          <span class="opacity-0 group-aria-[sort=ascending]:opacity-100 group-aria-[sort=descending]:rotate-180 group-aria-[sort=descending]:opacity-100">▲</span>
        </button>
      </th>
    </tr>
  </thead>
  <tbody>
    <tr><td class="py-1">Silla Ergonómica</td><td class="py-1 text-end tabular-nums">249,00 €</td></tr>
    <tr><td class="py-1">Webcam HD</td><td class="py-1 text-end tabular-nums">45,00 €</td></tr>
  </tbody>
</table>

Tu JavaScript (o tu componente de React o Angular) solo tiene que cambiar el atributo aria-sort y reordenar las filas. Toda la apariencia está en las clases, y funcionan igual en las dos versiones.

⚠️ Importante: No escribas aria-sort-descending:. En v4 compila sin error, pero aria-* con un nombre libre siempre genera el valor "true": el selector resultante es [aria-sort-descending="true"], un atributo que no existe, y la clase no hace nada. aria-invalid: funciona porque ese atributo sí vale "true". Para aria-sort, cuyos valores son ascending, descending y none, hace falta la forma con corchetes.


15. Tablas responsive: de tabla a tarjetas

El módulo 10 presentó las estrategias para el móvil: desplazamiento horizontal, ocultar columnas y convertir filas en tarjetas. Las dos primeras se resuelven con overflow-x-auto y hidden md:table-cell. Aquí vamos a detenernos en la tercera, la más delicada, con las clases de display para tablas:

Clase CSS
table display: table
table-header-group display: table-header-group
table-row-group display: table-row-group
table-row display: table-row
table-cell display: table-cell

La idea, en mobile first: en el móvil, cada elemento de la tabla pasa a ser block o flex. A partir de md: recuperan su display de tabla. Las etiquetas de cada dato salen de un atributo data-etiqueta con before:, para no duplicar texto:

<table id="adaptable" class="block w-full text-sm md:table">
  <thead class="hidden bg-slate-50 text-left text-slate-500 md:table-header-group">
    <tr>
      <th scope="col" class="px-4 py-2 font-medium">Pedido</th>
      <th scope="col" class="px-4 py-2 font-medium">Cliente</th>
      <th scope="col" class="px-4 py-2 font-medium">Ciudad</th>
      <th scope="col" class="px-4 py-2 text-end font-medium">Total</th>
    </tr>
  </thead>
  <tbody class="block space-y-3 md:table-row-group md:space-y-0 md:divide-y md:divide-slate-200">
    <tr class="block rounded-xl border border-slate-200 p-3 md:table-row md:rounded-none md:border-0 md:p-0">
      <td data-etiqueta="Pedido" class="flex justify-between py-1 font-semibold before:font-normal before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">5022</td>
      <td data-etiqueta="Cliente" class="flex justify-between py-1 before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">Diseños Creativos S.L.</td>
      <td data-etiqueta="Ciudad" class="flex justify-between py-1 before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">Barcelona</td>
      <td data-etiqueta="Total" class="flex justify-between py-1 tabular-nums before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:text-end md:before:content-none">1499,00 €</td>
    </tr>
    <tr class="block rounded-xl border border-slate-200 p-3 md:table-row md:rounded-none md:border-0 md:p-0">
      <td data-etiqueta="Pedido" class="flex justify-between py-1 font-semibold before:font-normal before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">5021</td>
      <td data-etiqueta="Cliente" class="flex justify-between py-1 before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">Talleres Martín S.L.</td>
      <td data-etiqueta="Ciudad" class="flex justify-between py-1 before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:before:content-none">Valencia</td>
      <td data-etiqueta="Total" class="flex justify-between py-1 tabular-nums before:text-slate-500 before:content-[attr(data-etiqueta)] md:table-cell md:px-4 md:py-2 md:text-end md:before:content-none">2462,05 €</td>
    </tr>
  </tbody>
</table>

Dos pedidos como tarjetas, con cada dato en una línea y su etiqueta a la izquierda

Figura 20. A 375 px cada fila es una tarjeta.

El mismo HTML como una tabla normal con cuatro columnas

Figura 21. A partir de md: vuelve a ser una tabla.

Tres cosas conviene tener presentes:

  • Es md:table-cell, no md:block. Cada elemento debe recuperar su valor exacto de la tabla. Si pones md:block en una celda, se rompen las columnas.
  • Se pierden las cabeceras de columna. Se ha comprobado con el árbol de accesibilidad de Chromium: a 375 px, la tabla, las filas y las celdas siguen ahí, aunque tengan display: block, pero los cuatro columnheader desaparecen porque <thead> está oculto. Por eso la etiqueta de before: es importante: el lector lee “Pedido 5022” como contenido de la celda. En otros navegadores no se ha comprobado; para asegurarte, añade role="table", role="row" y role="cell" a mano, que no cambian el aspecto.
  • Cuidado con hidden sola. Como viste en el módulo 07, en v4 hidden pierde frente a table-cell en el mismo elemento (se ha medido display: table-cell); en v3 gana hidden. Con variantes, como aquí, no hay problema.

💡 Consejo: Antes de convertir una tabla en tarjetas, pregúntate si hace falta. Si el usuario compara valores entre filas, las tarjetas lo dificultan, y un desplazamiento horizontal con la primera columna fija funciona mejor. Las tarjetas son buenas cuando cada fila se consulta por separado, como en una lista de pedidos.


16. <dialog>: ventanas modales nativas

<dialog> es la forma nativa de hacer una ventana modal. Con showModal(), el navegador hace por ti lo que antes costaba mucho JavaScript: la pone por encima de todo, sin z-index, atrapa el foco del teclado dentro de ella, deja el resto de la página inerte y la cierra con la tecla Esc.

<div id="modal-demo">
  <button onclick="document.getElementById('borrar').showModal()"
          class="cursor-pointer rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700">Eliminar producto</button>

  <dialog id="borrar" aria-labelledby="borrar-titulo"
          class="m-auto w-[calc(100%-2rem)] max-w-md rounded-2xl bg-white p-6 text-slate-900 shadow-xl backdrop:bg-slate-900/50 backdrop:backdrop-blur-[2px]">
    <h2 id="borrar-titulo" class="text-lg font-semibold">¿Eliminar Webcam HD?</h2>
    <p class="mt-2 text-sm text-slate-600">El producto dejará de aparecer en el catálogo. Esta acción no se puede deshacer.</p>
    <form method="dialog" class="mt-6 flex justify-end gap-3">
      <button value="cancelar" class="cursor-pointer rounded-lg border border-slate-300 px-4 py-2 font-medium text-slate-700 hover:bg-slate-50">Cancelar</button>
      <button value="eliminar" class="cursor-pointer rounded-lg bg-red-600 px-4 py-2 font-medium text-white hover:bg-red-700">Eliminar</button>
    </form>
  </dialog>
</div>

Ventana modal centrada que pregunta si se elimina el producto, con el fondo oscurecido y difuminado

Figura 22. El fondo oscuro es el pseudoelemento ::backdrop, que se pinta con backdrop:.

<form method="dialog"> cierra la ventana al pulsar cualquier botón, sin JavaScript. El value del botón pulsado queda en dialog.returnValue, de modo que puedes saber qué eligió el usuario en el evento close:

<script>
  document.getElementById('borrar').addEventListener('close', (evento) => {
    if (evento.target.returnValue === 'eliminar') {
      console.log('Eliminando el producto');
    }
  });
</script>

Secuencia de la vida de un dialog: cerrado con display none, abierto con showModal en la capa superior con backdrop, y cerrado con form method dialog o Esc

Figura 23. Ciclo de vida de un <dialog> modal. Su display lo gestiona el navegador.

Cuatro trampas de <dialog> con Tailwind

1. Sin m-auto no se centra. El navegador centra un dialog con margin: auto, y Preflight de v4 pone todos los márgenes a cero. Medido sin m-auto: left: 0 y top: 0, pegado a la esquina. Con m-auto, centrado. En 3.4 se centra sin la clase, pero tampoco molesta.

2. Una clase flex o grid lo deja siempre abierto. Un dialog cerrado se oculta porque el navegador le pone display: none, y tu clase lo pisa. Medido: <dialog class="flex"> sin abrir tiene display: flex y se ve en la página. Aplica esas clases solo al estar abierto, con open:flex:

<dialog class="m-auto max-h-[80dvh] flex-col open:flex"></dialog>

3. No hereda el color del texto. El navegador le da sus propios colores, canvastext y canvas. Medido en un dialog dentro de un bloque con text-slate-100: el texto es negro (rgb(0, 0, 0)) y el fondo blanco. Fija siempre bg-* y text-*, también los de dark:.

4. w-full deja la ventana pegada a los bordes del móvil. El navegador reserva un margen con max-width: calc(100% - 6px - 2em), y max-w-md lo sustituye. Por eso el ejemplo usa w-[calc(100%-2rem)] max-w-md.

Animar la apertura 🆕 v4.0

🆕 Desde Tailwind 4.0. No existe en la LTS (Tailwind 3.4): faltan starting: y transition-discrete, y transition no incluye display ni overlay. Si usas la LTS: escribe [@starting-style]:open:opacity-0, [transition-behavior:allow-discrete] y transition-[opacity,display,overlay], con open: igual que aquí. El bloque que sigue al ejemplo trae el código.

Como display pasa de none a block, el navegador no tiene un valor de partida del que animar. starting: lo proporciona, y transition-discrete permite que el cierre también se anime antes de ocultar el elemento. Lo explica a fondo el módulo 13:

<dialog id="animado" class="m-auto rounded-2xl bg-white p-6 text-slate-900 shadow-xl
                            opacity-0 transition transition-discrete duration-200
                            open:opacity-100 starting:open:opacity-0">
  Se abre y se cierra con un fundido.
</dialog>

Se ha comprobado que la transición se ejecuta al abrir y también al cerrar. El equivalente en Tailwind 3.4, comprobado en 3.4.19 de la misma manera:

<dialog id="animado-v3" class="m-auto rounded-2xl bg-white p-6 text-slate-900 shadow-xl
                               opacity-0 transition-[opacity,display,overlay] [transition-behavior:allow-discrete] duration-200
                               open:opacity-100 [@starting-style]:open:opacity-0">
  Se abre y se cierra con un fundido.
</dialog>

17. popover: paneles sin JavaScript

El atributo popover crea un panel flotante que se abre desde un botón, sin escribir código. Va a la capa superior, como un dialog, pero con otro carácter: no bloquea la página. Un clic fuera o la tecla Esc lo cierran.

<div id="popover-demo" class="h-52">
  <button popovertarget="ayuda-stock" class="cursor-pointer rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50">¿Qué significa "stock"?</button>
  <div id="ayuda-stock" popover class="m-auto max-w-xs rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-lg">
    Son las unidades disponibles en el almacén. Un producto con 0 unidades aparece como agotado.
  </div>
</div>
  <dialog> con showModal() popover
Se abre con JavaScript popovertarget, sin JavaScript
Resto de la página Inerte Sigue activa
Se cierra con Esc, form method="dialog" Esc, clic fuera, botón
Pintar fondo backdrop: backdrop:, poco habitual
Foco Atrapado dentro No atrapado
Uso Decisiones que bloquean Ayuda, menús, filtros

Regla práctica: si el usuario debe responder antes de seguir, usa <dialog>. Si es información o una herramienta auxiliar, usa popover.

Variantes para popover 🔄 v4.0

🔄 Cambia en Tailwind 4.0. En la LTS (Tailwind 3.4) open: solo genera [open], y un popover no tiene ese atributo. Si usas la LTS: usa [&:popover-open]: en lugar de open:, por ejemplo [&:popover-open]:flex. Con <dialog> y <details>, open: funciona igual en las dos versiones.

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

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

Las trampas de <dialog> se repiten: m-auto en v4, open:flex en lugar de flex, y fijar los colores. Medido en un popover dentro de un bloque oscuro: texto negro sobre fondo blanco.

Colocar el panel en un sitio

Sin m-auto, el navegador lo sitúa con inset: 0. Para llevarlo a una esquina, anula esa posición con inset-auto y coloca el panel:

<div id="esquina" class="h-40">
  <button popovertarget="avisos" class="cursor-pointer rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white">Avisos</button>
  <div id="avisos" popover class="inset-auto top-4 right-4 m-0 w-64 rounded-xl border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-lg">
    Quedan 2 productos sin stock.
  </div>
</div>

Dos paneles abiertos: uno de ayuda en el centro de la página y otro de avisos en la esquina superior derecha

Figura 24. Un popover centrado con m-auto, y otro en la esquina con inset-auto top-4 right-4.

Colocar el panel justo debajo de su botón requiere el posicionamiento con anclas de CSS (anchor positioning), que queda fuera de este curso porque aún no todos los navegadores lo admiten por completo.


18. Ejercicios

Ejercicio 1: Buscador unido

Crea un buscador con un campo y un botón pegados, de forma que el conjunto muestre un único anillo cuando el campo tenga el foco.

Ver solución

El envoltorio lleva el borde y reacciona con focus-within:. El campo no lleva borde ni anillo:

<form id="sol-buscador" role="search" class="flex max-w-md overflow-hidden rounded-lg border border-slate-300 bg-white
             focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-200" onsubmit="event.preventDefault()">
  <label for="buscar" class="sr-only">Buscar productos</label>
  <input id="buscar" type="search" placeholder="Buscar productos" class="min-w-0 flex-1 px-3 py-2 placeholder:text-slate-400 focus:outline-hidden">
  <button class="cursor-pointer bg-indigo-600 px-4 font-medium text-white hover:bg-indigo-700">Buscar</button>
</form>

focus:outline-hidden en el campo evita un segundo contorno dentro del anillo. overflow-hidden recorta el botón a las esquinas redondeadas, y min-w-0 deja que el campo se encoja: es el error de flexbox del módulo 08. En 3.4, cambia focus:outline-hidden por focus:outline-none.

Buscador con un solo anillo añil alrededor del campo y del botón

Figura 25. Solución del ejercicio 1, con el foco en el campo.

Ejercicio 2: Campo de precio con prefijo

Haz un campo con una etiqueta “EUR” a su izquierda, con aspecto de una sola pieza. Si el usuario escribe un valor negativo, el conjunto debe ponerse rojo tras salir del campo.

Ver solución
<label id="sol-prefijo" class="block max-w-xs text-sm font-medium text-slate-800">
  Precio
  <span class="mt-1 flex overflow-hidden rounded-lg border border-slate-300
               focus-within:ring-2 focus-within:ring-indigo-200
               has-user-invalid:border-red-600 has-user-invalid:ring-2 has-user-invalid:ring-red-200">
    <span class="bg-slate-100 px-3 py-2 text-slate-600">EUR</span>
    <input id="sol-precio" type="number" min="0" step="0.01" class="min-w-0 flex-1 bg-white px-3 py-2 text-end tabular-nums focus:outline-hidden">
  </span>
</label>

Como el borde lo lleva el envoltorio, el estado del campo tiene que subir hasta él, y por eso se usa has-user-invalid:. min="0" es lo que hace inválido un negativo. En 3.4, usa has-[:user-invalid]:border-red-600, has-[:user-invalid]:ring-2 y focus:outline-none.

Ejercicio 3: Comentario adaptable

Crea un textarea que empiece con una altura de unas tres líneas, crezca al escribir y a partir de 12 rem muestre su barra de desplazamiento. Da también la versión para Tailwind 3.4.

Ver solución
<textarea id="sol-comentario" aria-label="Comentario" placeholder="Cuéntanos tu opinión"
          class="field-sizing-content min-h-20 max-h-48 w-full max-w-md rounded-lg border border-slate-300 bg-white px-3 py-2"></textarea>

Con field-sizing-content, rows no sirve, así que el mínimo lo pone min-h-20. max-h-48 son 12 rem. Medido: con 12 líneas de texto la altura se queda en 192 px y el contenido se desplaza.

En 3.4, sustituye field-sizing-content por [field-sizing:content]. Lo demás es igual.

Ejercicio 4: Elegir el método de envío

Muestra tres métodos de envío como tarjetas, en fila a partir de sm:. La tarjeta elegida debe tener borde añil y un círculo relleno. Debe funcionar con el teclado.

Ver solución
<fieldset id="sol-envio" class="grid max-w-2xl gap-3 sm:grid-cols-3">
  <legend class="mb-2 text-sm font-medium text-slate-800">Método de envío</legend>
  <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 p-3 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-focus-visible:ring-2 has-focus-visible:ring-indigo-300">
    <input type="radio" name="metodo" checked class="mt-1 size-4 accent-indigo-600">
    <span><span class="block font-medium">Estándar</span><span class="text-sm text-slate-600">Gratis</span></span>
  </label>
  <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 p-3 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-focus-visible:ring-2 has-focus-visible:ring-indigo-300">
    <input type="radio" name="metodo" class="mt-1 size-4 accent-indigo-600">
    <span><span class="block font-medium">Urgente</span><span class="text-sm text-slate-600">6,90 €</span></span>
  </label>
  <label class="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-300 p-3 has-checked:border-indigo-600 has-checked:bg-indigo-50 has-focus-visible:ring-2 has-focus-visible:ring-indigo-300">
    <input type="radio" name="metodo" class="mt-1 size-4 accent-indigo-600">
    <span><span class="block font-medium">Recogida</span><span class="text-sm text-slate-600">Gratis</span></span>
  </label>
</fieldset>

Mismo name, para que el navegador los trate como un grupo. Aquí el botón de opción sigue a la vista, de modo que su propio contorno de foco ya sirve, y has-focus-visible: lo refuerza. Prueba con Tab y con las flechas.

Tres tarjetas de envío en fila, con Estándar marcada en añil

Figura 26. Solución del ejercicio 4.

Ejercicio 5: Tabla de inventario con alertas

Muestra productos y su stock. Si un producto está agotado, toda la fila debe verse en rojo claro; si queda poco, en ámbar. El estado debe venir de un atributo data-estado, y las clases deben estar en un único sitio, no en cada fila.

Ver solución
<div id="sol-inventario" class="max-w-md overflow-hidden rounded-xl border border-slate-200">
  <table class="w-full text-left text-sm
                [&_td]:px-4 [&_td]:py-2 [&_th]:px-4 [&_th]:py-2
                [&_tr[data-estado=agotado]]:bg-red-50 [&_tr[data-estado=agotado]]:text-red-800
                [&_tr[data-estado=bajo]]:bg-amber-50 [&_tr[data-estado=bajo]]:text-amber-800">
    <thead class="bg-slate-50 text-slate-500">
      <tr><th scope="col">Producto</th><th scope="col" class="text-end">Stock</th></tr>
    </thead>
    <tbody class="divide-y divide-slate-200">
      <tr><td>Monitor 24 FHD</td><td class="text-end tabular-nums">35</td></tr>
      <tr data-estado="bajo"><td>Torre Workstation</td><td class="text-end tabular-nums">4</td></tr>
      <tr data-estado="agotado"><td>Altavoz Inteligente</td><td class="text-end tabular-nums">0</td></tr>
    </tbody>
  </table>
</div>

La variante arbitraria selecciona las filas desde la tabla, y funciona en las dos versiones. La alternativa sería data-[estado=bajo]:bg-amber-50 en cada <tr>, pero entonces tendrías que repetirla fila a fila.

Tabla de inventario con una fila normal, una en ámbar y otra en rojo

Figura 27. Solución del ejercicio 5.

Ejercicio 6: Factura

Crea una tabla de líneas de pedido con columnas de ancho fijo y un pie con el total en negrita. Añade una nota debajo con <caption>.

Ver solución
<table id="sol-factura" class="w-full max-w-lg table-fixed caption-bottom text-sm">
  <caption class="mt-2 text-start text-slate-500">Pedido 5024. Importes con IVA.</caption>
  <thead class="border-b-2 border-slate-300 text-slate-600">
    <tr>
      <th scope="col" class="py-2 text-start font-medium">Producto</th>
      <th scope="col" class="w-20 py-2 text-end font-medium">Unidades</th>
      <th scope="col" class="w-28 py-2 text-end font-medium">Precio</th>
    </tr>
  </thead>
  <tbody class="divide-y divide-slate-200">
    <tr><td class="truncate py-2">Portátil Básico 15</td><td class="py-2 text-end tabular-nums">3</td><td class="py-2 text-end tabular-nums">499,00 €</td></tr>
  </tbody>
  <tfoot class="border-t-2 border-slate-300 font-semibold">
    <tr>
      <th scope="row" colspan="2" class="py-2 text-end">Total</th>
      <td class="py-2 text-end tabular-nums">1497,00 €</td>
    </tr>
  </tfoot>
</table>

table-fixed deja que w-20 y w-28 manden y que la primera columna se quede con el resto. Con collapse, el valor de Preflight, el borde de thead y tfoot sí se dibuja.

Ejercicio 7: Editar en una ventana

Haz un <dialog> para editar un cliente, con cabecera y pie fijos y un cuerpo con desplazamiento propio. Debe tener un alto máximo del 80 % de la pantalla.

Ver solución
<dialog id="sol-editar" aria-labelledby="sol-editar-titulo"
        class="m-auto max-h-[80dvh] w-[calc(100%-2rem)] max-w-md flex-col overflow-hidden rounded-2xl bg-white text-slate-900 shadow-xl backdrop:bg-slate-900/50 open:flex">
  <h2 id="sol-editar-titulo" class="border-b border-slate-200 px-6 py-4 text-lg font-semibold">Editar cliente 1001</h2>
  <form id="sol-editar-form" method="dialog" class="min-h-0 flex-1 space-y-4 overflow-y-auto px-6 py-4">
    <label class="block text-sm font-medium">Nombre <input value="Talleres Martín S.L." class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
    <label class="block text-sm font-medium">NIF <input value="B12345678" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
    <label class="block text-sm font-medium">Correo <input value="compras@talleresmartin.es" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
    <label class="block text-sm font-medium">Teléfono <input value="961234567" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
    <label class="block text-sm font-medium">Ciudad <input value="Valencia" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
    <label class="block text-sm font-medium">Límite de crédito <input type="number" value="10000" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></label>
  </form>
  <div class="flex justify-end gap-3 border-t border-slate-200 px-6 py-4">
    <button form="sol-editar-form" value="cancelar" class="cursor-pointer rounded-lg border border-slate-300 px-4 py-2 font-medium">Cancelar</button>
    <button form="sol-editar-form" value="guardar" class="cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white">Guardar</button>
  </div>
</dialog>
<button class="cursor-pointer rounded-lg border border-slate-300 px-4 py-2" onclick="document.getElementById('sol-editar').showModal()">Editar</button>

Cuatro claves:

  • open:flex en lugar de flex, para que no se vea estando cerrado.
  • min-h-0 flex-1 en el formulario: sin min-h-0, un elemento flex no se encoge por debajo de su contenido, y el pie saldría de la ventana.
  • overflow-hidden en el dialog, para que solo se desplace el cuerpo.
  • Los botones están fuera del <form>, y form="sol-editar-form" los enlaza con él. Así se quedan en el pie fijo y aun así cierran la ventana.

Ventana con cabecera, lista de campos recortada y pie con dos botones

Figura 28. Solución del ejercicio 7, con una pantalla de 480 px de alto.

Ejercicio 8: Panel de filtros

Crea un panel popover que salga por el lado derecho de la pantalla, con toda su altura, con casillas de categorías y un botón para cerrarlo.

Ver solución
<button popovertarget="sol-filtros" class="cursor-pointer rounded-lg border border-slate-300 px-3 py-1.5">Filtros</button>
<div id="sol-filtros" popover class="inset-y-0 right-0 left-auto m-0 h-dvh max-h-none w-72 bg-white p-5 text-slate-900 shadow-xl backdrop:bg-slate-900/30">
  <div class="flex items-center justify-between">
    <h2 class="font-semibold">Filtros</h2>
    <button popovertarget="sol-filtros" popovertargetaction="hide" aria-label="Cerrar" class="cursor-pointer rounded-md px-2 py-1 hover:bg-slate-100">✕</button>
  </div>
  <fieldset class="mt-4 space-y-2 text-sm">
    <legend class="mb-1 font-medium">Categoría</legend>
    <label class="flex items-center gap-2"><input type="checkbox" checked class="size-4 accent-indigo-600"> Portátiles</label>
    <label class="flex items-center gap-2"><input type="checkbox" class="size-4 accent-indigo-600"> Monitores</label>
    <label class="flex items-center gap-2"><input type="checkbox" class="size-4 accent-indigo-600"> Telefonía</label>
  </fieldset>
</div>

left-auto anula el inset: 0 del navegador en el lado izquierdo, max-h-none anula su altura máxima y popovertargetaction="hide" hace que el botón solo cierre. backdrop: también funciona en un popover, aunque la página siga activa.

Panel de filtros abierto en el lado derecho con la página oscurecida

Figura 29. Solución del ejercicio 8.

Ejercicio 9 (propuesto): Clientes en tarjetas

Toma la tabla de clientes de EMPRESA (nombre, NIF, correo, ciudad y límite de crédito) y conviértela en tarjetas por debajo de md:, con la técnica de la sección 15. Añade los role que devuelven la semántica.

Ejercicio 10 (propuesto): Formulario de pedido en 3.4

Reescribe el alta de producto de la sección 10 para que compile en Tailwind 3.4. Tendrás que sustituir outline-hidden, user-invalid:, peer-user-invalid:, field-sizing-content y scheme-*.

Ejercicio 11 (propuesto): Confirmar y deshacer

Combina la tabla de selección de la sección 14 con un <dialog>: el botón “Descatalogar” debe abrir una ventana de confirmación, animada con starting:, y solo al aceptar debe quitar las filas marcadas.


Resumen

Concepto API Idea clave
Punto de partida Preflight Sin borde ni relleno; en v4, además, fondo transparente
Foco de un campo focus:ring-2, focus:outline-hidden En 3.4, focus:outline-none. Nunca sin sustituto
Color nativo accent-* Casillas, opciones y deslizador
Botón de fichero file: Pseudoelemento ::file-selector-button
Control a medida sr-only, peer-checked: Conserva el <input> real, nunca hidden
Tarjeta de opción has-checked: 🔄 v4.0 En 3.4, has-[:checked]:
Plugin @plugin "@tailwindcss/forms" 🔄 v4.0 En 3.4, plugins: [...]. Colores con text-*
Solo con clases strategy: "class" form-input, form-select, form-checkbox…
Campo que crece field-sizing-content 🆕 v4.0 En 3.4, [field-sizing:content]. Pon min-h-* y max-h-*
Error tras interactuar user-invalid: 🆕 v4.1 En 3.4, [&:user-invalid]:
Mensaje de error peer-user-invalid: 🆕 v4.1 El campo va antes. En 3.4, peer-[:user-invalid]:
Formulario incompleto group-invalid: <form> también coincide con :invalid
Error del servidor aria-invalid: 🔄 v4.0 En 3.4, aria-[invalid=true]:
Modelo de tabla border-collapse, border-separate Con separate, las filas no pintan borde
Columnas table-auto, table-fixed truncate en celda exige table-fixed
Título caption-top, caption-bottom <caption> da nombre a la tabla
Cifras tabular-nums, text-end Se comparan de un vistazo
Bordes redondeados Envoltorio con overflow-hidden rounded-xl rounded-* no funciona con collapse
Sin repetir [&_td]:px-4 La utilidad de la celda no la sustituye
Cabecera fija sticky top-0, border-separate border-spacing-0 Con collapse, el borde no se pega
Selección de filas has-checked:, group-has-checked/lista: Sin JavaScript
Ordenación aria-sort, group-aria-[sort=ascending]: Siempre con corchetes, en las dos versiones
Tabla a tarjetas block md:table-cell, before:content-[attr(...)] Recupera cada display exacto
Ventana modal <dialog>, open:, backdrop: m-auto, open:flex y colores fijos
Animar la apertura starting:open:, transition-discrete 🆕 v4.0 En 3.4, [@starting-style]:
Panel flotante popover, popovertarget No bloquea. En 3.4, [&:popover-open]: