En esta página

Formularios y accesibilidad con Tailwind

12 min lectura TextoCap. 4 — Componentes y animación

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:

  1. Un <label> con for apuntando al id del input
  2. Un id único en el input
  3. aria-describedby apuntando al mensaje de error/ayuda si existe
  4. role="alert" y aria-live en 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.

sr-only no es solo decoración
La clase sr-only (screen-reader only) oculta visualmente un elemento pero lo mantiene accesible para lectores de pantalla. Úsala para labels de inputs cuyo propósito ya es claro visualmente por el contexto (ej: un campo de búsqueda dentro de un buscador), pero NUNCA elimines el label completamente.
focus-visible vs focus
Usa siempre focus-visible: en lugar de focus: para los estilos de ring/outline. focus: se activa con mouse Y teclado. focus-visible: solo se activa con teclado, manteniendo la interfaz limpia para usuarios de ratón mientras sigue siendo accesible para usuarios de teclado.
<!-- 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>