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"yinput 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
textareacrezca con su contenido confield-sizing-* - Validar sin JavaScript con
invalid:yuser-invalid:, y mostrar mensajes conpeeryhas-* - 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-fixedycaption-* - 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>ypopoverconopen:,backdrop:ystarting:, 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.csssin 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>

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-400y los botones llevancursor: pointer; en 4.0 el texto de ejemplo es el color actual al 50 % y los botones usancursor: default. Si usas la LTS: no necesitascursor-pointeren los botones; en cualquier versión, fija el color conplaceholder:text-slate-400para 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 |

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-hiddenno existe en la LTS (Tailwind 3.4), youtline-noneno significa lo mismo en las dos versiones. Si usas la LTS: escribefocus:outline-none, que en 3.4 hace lo queoutline-hiddenhace 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>

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-onlyesconde la casilla sin quitarla. Conhiddendejaría de recibir el foco.peerypeer-checked:hacen que el<span>reaccione a la casilla, que debe ir antes en el HTML. Lo viste enpeer: el hermano manda.peer-focus-visible:ring-2devuelve 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 arbitrariahas-[:checked]:, que genera el mismo selector y sirve igual en las dos versiones. Lo mismo vale parahas-focus-visible:, que pasa a serhas-[: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>

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
hiddennidisplay: nonepara esconder el control. Dejaría de recibir el foco, y el teclado no podría llegar a él. Por eso la clase es siempresr-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 entailwind.config.js, conplugins: [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>

Figura 5. Sin el plugin: solo Preflight.

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 ablue-600aunque hayas puestoborder-slate-300. En 4.x el plugin vive en la capabase, y la utilidad gana siempre. Si usas la LTS: añadefocus:border-indigo-500yfocus:ring-indigo-500en 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.
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 eventoinput.
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>

Figura 8. Arriba, tamaño fijo con rows="2" y el texto oculto. Abajo, field-sizing-content.
Tres detalles salen en la práctica:
rowsdeja de tener efecto. Medido: untextareaconrows="2"y una sola línea mide 24 px, no dos líneas. Fija un mínimo conmin-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>confield-sizing-contentmide lo que su texto, y hay que darlemin-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 hermanospeer-[: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>

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

Figura 10. El mismo formulario después de editar el segundo campo y salir de él.
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-invalides 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>

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-describedbyy añade algo de JavaScript que actualicearia-invaliden 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:invalidsi contiene algún campo inválido. Congroup-invalid:el botón queda apagado hasta que el formulario esté completo. Se ha medido una opacidad de0.5en las dos versiones.group-has-user-invalid:muestra un resumen encima de los campos, en un sitio al quepeerno llega.
Sus equivalentes en 3.4 son has-[:user-invalid]: y group-has-[:user-invalid]:.
💡 Consejo:
group-invalid:opacity-50solo cambia el aspecto. Si además ponesdisableden 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: escribearia-[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>

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

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

Figura 15. A 375 px todo queda en una columna.
Detalles que merece la pena mirar:
grid gap-5 sm:grid-cols-2consm:col-span-2en 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-numsen el precio: las cifras se alinean como en una columna contable.pe-8deja sitio al símbolo€, que va encima conabsolute.- Con el
textarea,min-h-20sustituye arows, quefield-sizing-contentignora.
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>

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>

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>

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-0va en las celdas<th>y no en<thead>o<tr>. Es lo que funciona de forma más fiable entre navegadores.bg-whitees obligatorio. Sin fondo, las filas se verían a través de la cabecera.- El contenedor con
overflow-autonecesita una altura (aquíh-56). Sin ella, el que se desplaza es la página ystickyno 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>

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, peroaria-*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". Paraaria-sort, cuyos valores sonascending,descendingynone, 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>

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

Figura 21. A partir de md: vuelve a ser una tabla.
Tres cosas conviene tener presentes:
- Es
md:table-cell, nomd:block. Cada elemento debe recuperar su valor exacto de la tabla. Si ponesmd:blocken 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 cuatrocolumnheaderdesaparecen porque<thead>está oculto. Por eso la etiqueta debefore:es importante: el lector lee “Pedido 5022” como contenido de la celda. En otros navegadores no se ha comprobado; para asegurarte, añaderole="table",role="row"yrole="cell"a mano, que no cambian el aspecto. - Cuidado con
hiddensola. Como viste en el módulo 07, en v4hiddenpierde frente atable-cellen el mismo elemento (se ha medidodisplay: table-cell); en v3 ganahidden. 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>

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>
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:ytransition-discrete, ytransitionno incluyedisplaynioverlay. Si usas la LTS: escribe[@starting-style]:open:opacity-0,[transition-behavior:allow-discrete]ytransition-[opacity,display,overlay], conopen: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 unpopoverno tiene ese atributo. Si usas la LTS: usa[&:popover-open]:en lugar deopen:, 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>

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.

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.

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.

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

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.

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]: |