En esta página
Formularios y accesibilidad con Tailwind
Formularios y accesibilidad con Tailwind
Los formularios son uno de los componentes más críticos de cualquier aplicación web. Son el principal punto de interacción del usuario, y también el principal punto de fallo de accesibilidad. En esta lección aprenderás a estilizar formularios con Tailwind de forma correcta, semántica y accesible.
El reseteo de formularios con @tailwindcss/forms
Por defecto, los elementos de formulario tienen estilos muy variables entre navegadores y son difíciles de customizar. El plugin @tailwindcss/forms resetea estos estilos a una base limpia:
npm install @tailwindcss/forms/* styles.css */
@import "tailwindcss";
@plugin "@tailwindcss/forms";Esto simplifica enormemente el estilizado de input, select, textarea, checkbox, radio y file.
Inputs de texto
Estructura básica accesible
Un input accesible siempre tiene:
- Un
<label>conforapuntando aliddel input - Un
idúnico en el input aria-describedbyapuntando al mensaje de error/ayuda si existerole="alert"yaria-liveen mensajes de error dinámicos
<div class="space-y-1.5">
<label
for="email"
class="block text-sm font-medium text-gray-700"
>
Correo electrónico
</label>
<input
type="email"
id="email"
name="email"
autocomplete="email"
required
aria-required="true"
aria-describedby="email-hint email-error"
class="w-full rounded-xl border border-gray-300
px-4 py-2.5 text-gray-900 bg-white
placeholder:text-gray-400
outline-none transition-colors
focus-visible:border-blue-500
focus-visible:ring-2 focus-visible:ring-blue-500/20
invalid:border-red-400 invalid:bg-red-50/50"
placeholder="[email protected]"
/>
<p id="email-hint" class="text-xs text-gray-500">
Nunca compartiremos tu email con terceros.
</p>
<p id="email-error" role="alert" aria-live="polite"
class="text-sm text-red-600 hidden">
Por favor ingresa un email válido.
</p>
</div>Estado de error con clases condicionales
<!-- Sin error -->
<input class="w-full rounded-xl border border-gray-300
focus-visible:border-blue-500 focus-visible:ring-2
focus-visible:ring-blue-500/20 outline-none" />
<!-- Con error (agrega clases de error dinámicamente) -->
<input class="w-full rounded-xl border border-red-400 bg-red-50
focus-visible:border-red-500 focus-visible:ring-2
focus-visible:ring-red-500/20 outline-none
text-red-900 placeholder:text-red-400" />Variantes de estado con CSS nativo
<!-- Usando pseudo-clases CSS válidas en Tailwind -->
<input
type="text"
class="w-full rounded-xl border px-4 py-2.5 outline-none
border-gray-300
focus-visible:border-blue-500 focus-visible:ring-2 focus-visible:ring-blue-500/20
invalid:border-red-400
disabled:bg-gray-50 disabled:text-gray-400 disabled:cursor-not-allowed
read-only:bg-gray-50 read-only:cursor-default"
/>Textarea
<div class="space-y-1.5">
<label for="message" class="block text-sm font-medium text-gray-700">
Mensaje
</label>
<textarea
id="message"
name="message"
rows="4"
class="w-full rounded-xl border border-gray-300
px-4 py-3 text-gray-900 bg-white
placeholder:text-gray-400 leading-relaxed
outline-none transition-colors resize-none
focus-visible:border-blue-500 focus-visible:ring-2
focus-visible:ring-blue-500/20"
placeholder="Escribe tu mensaje aquí..."
></textarea>
<p class="text-xs text-gray-500 text-right">
<span id="char-count">0</span>/500 caracteres
</p>
</div>Select
<div class="space-y-1.5">
<label for="country" class="block text-sm font-medium text-gray-700">
País
</label>
<div class="relative">
<select
id="country"
name="country"
class="w-full appearance-none rounded-xl border border-gray-300
px-4 py-2.5 pr-10 text-gray-900 bg-white
outline-none transition-colors cursor-pointer
focus-visible:border-blue-500 focus-visible:ring-2
focus-visible:ring-blue-500/20"
>
<option value="" disabled selected>Selecciona un país</option>
<option value="bo">Bolivia</option>
<option value="ar">Argentina</option>
<option value="mx">México</option>
<option value="es">España</option>
</select>
<!-- Icono de flecha personalizado -->
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 11L3 6h10l-5 5z"/>
</svg>
</div>
</div>
</div>Checkbox y Radio personalizados
Checkbox accesible
El patrón peer sr-only es el más recomendado: el input real es invisible pero sigue siendo tabulable y operable por teclado y lectores de pantalla:
<fieldset class="space-y-3">
<legend class="text-sm font-semibold text-gray-700 mb-3">
Intereses
</legend>
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative">
<input type="checkbox" name="interests" value="js" class="peer sr-only" />
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center
border-gray-300 bg-white
peer-checked:bg-blue-600 peer-checked:border-blue-600
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-1
group-hover:border-blue-400
transition-all"
>
<svg class="w-3 h-3 text-white scale-0 peer-checked:scale-100
transition-transform" viewBox="0 0 12 12" fill="currentColor">
<path d="M10.28 2.28L3.989 8.575 1.695 6.28A1 1 0 00.28 7.695l3 3a1 1 0 001.414 0l7-7A1 1 0 0010.28 2.28z"/>
</svg>
</div>
</div>
<span class="text-sm text-gray-700">JavaScript / TypeScript</span>
</label>
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative">
<input type="checkbox" name="interests" value="css" class="peer sr-only" />
<div
class="w-5 h-5 rounded border-2 flex items-center justify-center
border-gray-300 bg-white
peer-checked:bg-blue-600 peer-checked:border-blue-600
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-1
group-hover:border-blue-400
transition-all"
>
<svg class="w-3 h-3 text-white scale-0 peer-checked:scale-100
transition-transform" viewBox="0 0 12 12" fill="currentColor">
<path d="M10.28 2.28L3.989 8.575 1.695 6.28A1 1 0 00.28 7.695l3 3a1 1 0 001.414 0l7-7A1 1 0 0010.28 2.28z"/>
</svg>
</div>
</div>
<span class="text-sm text-gray-700">CSS / Tailwind</span>
</label>
</fieldset>Radio buttons
<fieldset class="space-y-3">
<legend class="text-sm font-semibold text-gray-700 mb-3">
Plan de suscripción
</legend>
<label
class="flex items-center justify-between p-4 rounded-xl border-2 cursor-pointer
border-gray-200 hover:border-gray-300
has-[input:checked]:border-blue-500 has-[input:checked]:bg-blue-50
transition-colors"
>
<div class="flex items-center gap-3">
<input
type="radio"
name="plan"
value="free"
class="peer sr-only"
/>
<!-- Círculo visual del radio -->
<div
class="w-4 h-4 rounded-full border-2 border-gray-300 flex items-center
justify-center peer-checked:border-blue-500
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-1 transition-colors"
>
<div class="w-2 h-2 rounded-full bg-blue-500 scale-0
peer-checked:scale-100 transition-transform"></div>
</div>
<div>
<p class="font-semibold text-gray-900">Gratuito</p>
<p class="text-sm text-gray-500">5 cursos incluidos</p>
</div>
</div>
<span class="font-bold text-gray-900">$0/mes</span>
</label>
<label
class="flex items-center justify-between p-4 rounded-xl border-2 cursor-pointer
border-gray-200 hover:border-gray-300
has-[input:checked]:border-blue-500 has-[input:checked]:bg-blue-50
transition-colors"
>
<div class="flex items-center gap-3">
<input type="radio" name="plan" value="pro" class="peer sr-only" />
<div
class="w-4 h-4 rounded-full border-2 border-gray-300 flex items-center
justify-center peer-checked:border-blue-500
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-1 transition-colors"
>
<div class="w-2 h-2 rounded-full bg-blue-500 scale-0
peer-checked:scale-100 transition-transform"></div>
</div>
<div>
<p class="font-semibold text-gray-900">Pro</p>
<p class="text-sm text-gray-500">Acceso ilimitado</p>
</div>
</div>
<span class="font-bold text-blue-600">$19/mes</span>
</label>
</fieldset>Toggle switch accesible
<div class="flex items-center justify-between">
<label for="notifications" class="text-sm font-medium text-gray-700">
Notificaciones por email
</label>
<div class="relative">
<input
type="checkbox"
role="switch"
id="notifications"
class="peer sr-only"
checked
/>
<div
class="w-11 h-6 rounded-full bg-gray-200 peer-checked:bg-blue-600
transition-colors cursor-pointer
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-2"
></div>
<div
class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow-sm
transition-transform peer-checked:translate-x-5"
></div>
</div>
</div>Accesibilidad con sr-only
La clase sr-only es fundamental para la accesibilidad. Oculta visualmente pero mantiene el elemento en el árbol de accesibilidad:
<!-- Label visible solo para lectores de pantalla -->
<nav aria-label="Navegación principal">
<form role="search">
<label for="search" class="sr-only">Buscar cursos</label>
<input
type="search"
id="search"
placeholder="Buscar..."
class="w-full rounded-full border px-4 py-2 outline-none
focus-visible:ring-2 focus-visible:ring-blue-500"
/>
<button type="submit" class="sr-only">Buscar</button>
</form>
</nav>
<!-- Texto adicional para lectores de pantalla -->
<a href="/cursos/tailwind">
Tailwind CSS v4
<span class="sr-only">— Ver curso completo</span>
</a>
<!-- Conteo de resultados para lectores de pantalla -->
<p role="status" aria-live="polite" aria-atomic="true">
<span class="sr-only">
Se encontraron 24 cursos para tu búsqueda
</span>
</p>Formulario completo: registro de usuario
<main class="min-h-screen bg-gray-50 flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Crea tu cuenta</h1>
<p class="text-gray-500 mt-2">Gratis para siempre, sin tarjeta de crédito</p>
</div>
<form
class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 space-y-5"
novalidate
>
<!-- Nombre -->
<div class="space-y-1.5">
<label for="fullname" class="block text-sm font-medium text-gray-700">
Nombre completo <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
type="text" id="fullname" name="fullname"
autocomplete="name" required aria-required="true"
class="w-full rounded-xl border border-gray-300 px-4 py-2.5
outline-none transition-colors focus-visible:border-blue-500
focus-visible:ring-2 focus-visible:ring-blue-500/20
placeholder:text-gray-400 text-gray-900"
placeholder="David Morales"
/>
</div>
<!-- Email -->
<div class="space-y-1.5">
<label for="reg-email" class="block text-sm font-medium text-gray-700">
Correo electrónico <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
type="email" id="reg-email" name="email"
autocomplete="email" required aria-required="true"
class="w-full rounded-xl border border-gray-300 px-4 py-2.5
outline-none transition-colors focus-visible:border-blue-500
focus-visible:ring-2 focus-visible:ring-blue-500/20
invalid:border-red-400 placeholder:text-gray-400 text-gray-900"
placeholder="[email protected]"
/>
</div>
<!-- Password -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-medium text-gray-700">
Contraseña <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
type="password" id="password" name="password"
autocomplete="new-password" required aria-required="true"
minlength="8"
aria-describedby="password-hint"
class="w-full rounded-xl border border-gray-300 px-4 py-2.5
outline-none transition-colors focus-visible:border-blue-500
focus-visible:ring-2 focus-visible:ring-blue-500/20
placeholder:text-gray-400 text-gray-900"
placeholder="Mínimo 8 caracteres"
/>
<p id="password-hint" class="text-xs text-gray-500">
Al menos 8 caracteres, con letras y números.
</p>
</div>
<!-- Términos -->
<label class="flex items-start gap-3 cursor-pointer">
<div class="relative mt-0.5">
<input type="checkbox" name="terms" required class="peer sr-only" />
<div
class="w-5 h-5 rounded border-2 border-gray-300 flex items-center
justify-center peer-checked:bg-blue-600 peer-checked:border-blue-600
peer-focus-visible:ring-2 peer-focus-visible:ring-blue-500
peer-focus-visible:ring-offset-1 transition-all"
>
<svg class="w-3 h-3 text-white opacity-0 peer-checked:opacity-100"
viewBox="0 0 12 12" fill="currentColor">
<path d="M10.28 2.28L3.989 8.575 1.695 6.28A1 1 0 00.28 7.695l3 3a1 1 0 001.414 0l7-7A1 1 0 0010.28 2.28z"/>
</svg>
</div>
</div>
<span class="text-sm text-gray-600 leading-relaxed">
Acepto los
<a href="/terminos" class="text-blue-600 hover:underline">términos de servicio</a>
y la
<a href="/privacidad" class="text-blue-600 hover:underline">política de privacidad</a>
</span>
</label>
<!-- Botón submit -->
<button
type="submit"
class="w-full bg-blue-600 hover:bg-blue-700 active:bg-blue-800
text-white font-semibold py-3 rounded-xl
transition-colors duration-150
focus-visible:outline-none focus-visible:ring-2
focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
Crear cuenta gratis
</button>
<p class="text-center text-sm text-gray-500">
¿Ya tienes cuenta?
<a href="/login" class="text-blue-600 font-medium hover:underline">
Inicia sesión
</a>
</p>
</form>
</div>
</main>Resumen
Los formularios accesibles con Tailwind requieren: <label> con for siempre visible (o sr-only cuando el contexto es claro), focus-visible: para estilos de foco que respetan las preferencias del usuario, aria-describedby para mensajes de error/ayuda, peer sr-only para inputs visualmente personalizados, y role="alert" con aria-live para mensajes dinámicos. La accesibilidad no es opcional — es parte del diseño.
<!-- Formulario de registro completamente accesible -->
<form class="space-y-6 max-w-md mx-auto" novalidate>
<!-- Campo de texto con label visible -->
<div class="space-y-1.5">
<label
for="name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Nombre completo
<span class="text-red-500 ml-1" aria-hidden="true">*</span>
</label>
<input
type="text"
id="name"
name="name"
autocomplete="name"
required
aria-required="true"
aria-describedby="name-error"
class="w-full rounded-xl border border-gray-300 dark:border-gray-600
px-4 py-2.5 text-gray-900 dark:text-white
bg-white dark:bg-gray-800
placeholder:text-gray-400
outline-none transition-colors
focus-visible:border-blue-500
focus-visible:ring-2 focus-visible:ring-blue-500/25
invalid:border-red-400 invalid:bg-red-50
dark:focus-visible:border-blue-400"
placeholder="Tu nombre completo"
/>
<!-- Mensaje de error (oculto hasta que falla la validación) -->
<p
id="name-error"
role="alert"
class="text-sm text-red-600 dark:text-red-400 hidden"
aria-live="polite"
>
El nombre es obligatorio.
</p>
</div>
</form>
Inicia sesión para guardar tu progreso