Accesibilidad de formularios
Atributos ARIA, gestión del focus y anuncios de error — haz que cada formulario sea usable para todos.
Receta
Tarjeta de receta de referencia rápida — lista para copiar y pegar.
// Patrón de campo de formulario accesible
function AccessibleField({
id,
label,
error,
required,
description,
children,
}: {
id: string;
label: string;
error?: string;
required?: boolean;
description?: string;
children: React.ReactNode;
}) {
const descIds = [
description ? `${id}-desc` : null,
error ? `${id}-error` : null,
].filter(Boolean).join(" ");
return (
<div>
<label htmlFor={id}>
{label}
{required && <span aria-hidden="true" className="text-red-500"> *</span>}
{required && <span className="sr-only"> (requerido)</span>}
</label>
<div
// Clona propiedades aria a los hijos, o envuelve el input aquí
>
{children}
</div>
{description && (
<p id={`${id}-desc`} className="text-sm text-gray-500">{description}</p>
)}
{error && (
<p id={`${id}-error`} role="alert" className="text-sm text-red-600">{error}</p>
)}
</div>
);
}
// Uso
<AccessibleField id="email" label="Correo electrónico" error={errors.email} required>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby="email-desc email-error"
aria-required="true"
/>
</AccessibleField>Cuándo usarlo: Todo formulario. La accesibilidad no es opcional — es un requisito para cualquier aplicación en producción.
Ejemplo funcional
"use client";
import { useRef, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, "El nombre es requerido"),
email: z.string().email("Por favor, introduce un correo electrónico válido"),
subject: z.enum(["general", "support", "billing"], {
required_error: "Por favor, selecciona un asunto",
}),
message: z.string().min(20, "El mensaje debe tener al menos 20 caracteres"),
});
type FormData = z.infer<typeof Schema>;
export function AccessibleContactForm() {
const errorSummaryRef = useRef<HTMLDivElement>(null);
const [announced, setAnnounced] = useState("");
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful, submitCount },
setFocus,
reset,
} = useForm<FormData>({
resolver: zodResolver(Schema),
mode: "onBlur",
});
// Enfoca el primer campo con error después de un envío fallido
useEffect(() => {
if (submitCount === 0) return;
const errorKeys = Object.keys(errors) as (keyof FormData)[];
if (errorKeys.length > 0) {
setFocus(errorKeys[0]);
errorSummaryRef.current?.focus();
}
}, [errors, submitCount, setFocus]);
async function onSubmit(data: FormData) {
await new Promise((r) => setTimeout(r, 1000));
console.log("Submitted:", data);
setAnnounced("Tu mensaje ha sido enviado exitosamente.");
reset();
}
const errorEntries = Object.entries(errors).filter(([k]) => k !== "root");
return (
<div className="max-w-md">
{/* Región live para anuncios de estado */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announced}
</div>
{/* Resumen de errores — se anuncia al aparecer */}
{errorEntries.length > 0 && submitCount > 0 && (
<div
ref={errorSummaryRef}
tabIndex={-1}
role="alert"
aria-labelledby="error-heading"
className="mb-4 rounded border border-red-200 bg-red-50 p-4 outline-none focus:ring-2 focus:ring-red-500"
>
<h2 id="error-heading" className="font-medium text-red-800">
Hay {errorEntries.length === 1 ? "1 error" : `${errorEntries.length} errores`} en tu envío
</h2>
<ul className="mt-2 list-inside list-disc text-sm text-red-700">
{errorEntries.map(([key, err]) => (
<li key={key}>
<a href={`#${key}`} className="underline hover:no-underline">
{err?.message}
</a>
</li>
))}
</ul>
</div>
)}
{isSubmitSuccessful && (
<div role="status" className="mb-4 rounded border border-green-200 bg-green-50 p-4 text-green-800">
¡Mensaje enviado exitosamente!
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} noValidate aria-label="Formulario de contacto">
<fieldset disabled={isSubmitting} className="space-y-4">
<legend className="sr-only">Información de contacto</legend>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Nombre <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(requerido)</span>
</label>
<input
id="name"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.name ? "border-red-500" : ""}`}
/>
{errors.name && (
<p id="name-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.name.message}
</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Correo electrónico <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(requerido)</span>
</label>
<input
id="email"
type="email"
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
aria-required="true"
autoComplete="email"
className={`mt-1 w-full rounded border p-2 ${errors.email ? "border-red-500" : ""}`}
/>
{errors.email && (
<p id="email-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.email.message}
</p>
)}
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium">
Asunto <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(requerido)</span>
</label>
<select
id="subject"
{...register("subject")}
aria-invalid={!!errors.subject}
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.subject ? "border-red-500" : ""}`}
>
<option value="">-- Selecciona --</option>
<option value="general">Consulta general</option>
<option value="support">Soporte</option>
<option value="billing">Facturación</option>
</select>
{errors.subject && (
<p role="alert" className="mt-1 text-sm text-red-600">{errors.subject.message}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Mensaje <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(requerido)</span>
</label>
<textarea
id="message"
{...register("message")}
rows={4}
aria-invalid={!!errors.message}
aria-describedby="message-hint message-error"
aria-required="true"
className={`mt-1 w-full rounded border p-2 ${errors.message ? "border-red-500" : ""}`}
/>
<p id="message-hint" className="mt-1 text-xs text-gray-500">
Mínimo 20 caracteres
</p>
{errors.message && (
<p id="message-error" role="alert" className="mt-1 text-sm text-red-600">
{errors.message.message}
</p>
)}
</div>
<button
type="submit"
aria-disabled={isSubmitting}
className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Enviando..." : "Enviar mensaje"}
</button>
</fieldset>
</form>
</div>
);
}Lo que esto demuestra:
- Resumen de errores con enlaces de anclaje a campos inválidos
aria-invalid,aria-describedby,aria-requireden cada camporole="alert"para mensajes de error (anuncio inmediato)- Región
aria-live="polite"para anuncios de éxito/estado - Gestión del focus — el resumen de errores y el primer campo con error reciben el focus
- Texto
sr-onlypara contenido solo para lectores de pantalla noValidatepara deshabilitar la validación del navegador y usar mensajes personalizadosautoCompletepara campos de correo electrónico
Profundización
Cómo funciona
aria-invalid="true"le dice a la tecnología asistiva que el campo tiene un erroraria-describedbyvincula la entrada a sus elementos de descripción y error (IDs separados por espacios)role="alert"crea una región ARIA live que anuncia contenido inmediatamente cuando aparecearia-live="polite"anuncia cambios en la siguiente oportunidad disponible (no interrumpe)tabIndex={-1}hace que un elemento sea enfocable mediante JS (.focus()) pero no en el orden de tabulaciónfieldset+disableddeshabilita todas las entradas dentro durante el envío- El resumen de errores con
<a href="#fieldId">permite a los usuarios saltar al campo problemático
Variaciones
Anuncios automáticos de cambios de estado del formulario:
function FormStatus({ isSubmitting, errorCount }: { isSubmitting: boolean; errorCount: number }) {
const message = isSubmitting
? "Enviando formulario..."
: errorCount > 0
? `El formulario tiene ${errorCount} ${errorCount === 1 ? "error" : "errores"}`
: "";
return (
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{message}
</div>
);
}Enlace para saltar a errores:
{errorEntries.length > 0 && (
<a href="#error-summary" className="sr-only focus:not-sr-only focus:absolute focus:p-2">
Saltar al resumen de errores
</a>
)}Contador de caracteres con retroalimentación live:
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current;
return (
<p
aria-live="polite"
aria-atomic="true"
className={`text-xs ${remaining < 20 ? "text-amber-600" : "text-gray-500"}`}
>
{remaining} caracteres restantes
</p>
);
}Notas de TypeScript
// Propiedades aria type-safe
const ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;
// setFocus acepta nombres de campos tipados
const { setFocus } = useForm<FormData>();
setFocus("email"); // OK
setFocus("typo"); // Error TSTrampas
-
Demasiados elementos
role="alert"— Cada uno se anuncia inmediatamente, abrumando al usuario. Solución: Usa un resumen de errores conrole="alert"y errores individuales sin él, o renderiza errores en secuencia. -
aria-describedbycon IDs faltantes — Referenciar un ID inexistente se ignora silenciosamente pero es confuso. Solución: Solo incluye IDs que estén actualmente renderizados. -
Indicación de error solo por color — El color solo falla WCAG. Solución: Combina color con mensajes de texto, iconos o cambios de borde.
-
Deshabilitar el botón de envío —
disabledelimina el botón del orden de tabulación. Solución: Usaaria-disabled="true"con un manejador de clic que prevenga el envío, manteniendo el botón enfocable. -
Auto-focus al cargar la página — Mover el focus al cargar desorientas a los usuarios de lectores de pantalla. Solución: Solo mueve el focus en respuesta a acciones del usuario (como el envío del formulario).
-
Falta de
noValidate— Los popups de validación del navegador son inaccesibles e inconsistentes. Solución: AñadenoValidatey maneja toda la validación en JS con ARIA adecuado.
Alternativas
| Alternativa | Úsalo cuando | No lo uses cuando |
|---|---|---|
| shadcn Form | Maneja ARIA automáticamente mediante FormControl | Necesitas un comportamiento ARIA personalizado |
| react-aria (Adobe) | Quieres una biblioteca headless con soporte ARIA completo | Ya tienes componentes accesibles |
| Primitivos de Radix UI | Necesitas primitivos accesibles (diálogos, selects, etc.) | Los inputs nativos simples son suficientes |
| Validación HTML nativa | Quieres validación básica sin JS | Necesitas mensajes de error personalizados o reglas complejas |
Preguntas frecuentes
¿Qué hace aria-invalid y cuándo deberías establecerlo en true?
aria-invalid="true"le dice a la tecnología asistiva que el campo actualmente tiene un error- Establécelo cuando el campo tenga un error de validación:
aria-invalid={!!errors.fieldName} - Los lectores de pantalla anuncian el estado inválido cuando el usuario enfoca el campo
¿Por qué usar aria-describedby con IDs separados por espacios?
aria-describedbyvincula una entrada a uno o más elementos que la describen- IDs separados por espacios te permiten combinar descripción y error:
aria-describedby="email-desc email-error" - Solo incluye IDs de elementos que estén actualmente renderizados en el DOM
¿Cuál es la diferencia entre role="alert" y aria-live="polite"?
role="alert"anuncia contenido inmediatamente e interrumpe el anuncio actualaria-live="polite"espera hasta que el lector de pantalla termine el anuncio actual- Usa
role="alert"para errores; usaaria-live="polite"para mensajes de éxito/estado
¿Por qué el resumen de errores usa tabIndex={-1}?
tabIndex={-1}hace que el elemento sea enfocable mediante JavaScript.focus()pero lo mantiene fuera del orden de tabulación normal- Esto te permite mover programáticamente el focus al resumen de errores después de un envío fallido
- Sin él, llamar a
.focus()en un<div>no haría nada
Trampa: ¿Qué sucede si usas demasiados elementos role="alert" en un formulario?
- Cada elemento
role="alert"se anuncia inmediatamente cuando aparece - Múltiples anuncios simultáneos abruman a los usuarios de lectores de pantalla
- Solución: usa un resumen de errores único con
role="alert"y omite el role en errores individuales de campo
Trampa: ¿Por qué deberías evitar deshabilitar el botón de envío con el atributo disabled?
disabledelimina el botón del orden de tabulación completamente- Los usuarios de teclado no pueden alcanzarlo o interactuar con él
- Usa
aria-disabled="true"con un manejador de clic que prevenga el envío en su lugar, manteniendo el botón enfocable
¿Por qué añadir noValidate al elemento form?
- Los popups de validación nativos del navegador son inaccesibles e inconsistentes visualmente entre navegadores
noValidatelos deshabilita para que puedas manejar toda la validación en JavaScript con atributos ARIA adecuados- Mantienes control total sobre los mensajes de error y la gestión del focus
¿Cómo la clase sr-only ayuda con indicadores de campo requerido?
- El asterisco visual (
*) usaaria-hidden="true"para que los lectores de pantalla lo ignoren - Un
<span className="sr-only">(requerido)</span>separado proporciona el equivalente de texto - Esto asegura que tanto usuarios videntes como usuarios de lectores de pantalla entiendan que el campo es requerido
¿Cómo type-checas atributos aria en TypeScript?
const ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;- Usa
satisfies React.AriaAttributespara asegurar que solo se incluyen propiedades ARIA válidas
¿Cómo setFocus de react-hook-form proporciona type safety?
const { setFocus } = useForm<FormData>();
setFocus("email"); // OK — "email" es una clave de FormData
setFocus("typo"); // Error TS — "typo" no es una clavesetFocusacepta solo nombres de campos del parámetro de tipo genérico del formulario
¿Cuál es el propósito del elemento fieldset con disabled en el ejemplo funcional?
- Envolver inputs en
<fieldset disabled={isSubmitting}>deshabilita todas las entradas hijas a la vez durante el envío - Esto previene el envío doble sin deshabilitar cada entrada individualmente
- También proporciona un agrupamiento semántico con la
<legend>para lectores de pantalla
¿Cómo deberías manejar la gestión del focus después de un envío de formulario fallido?
- Enfoca el resumen de errores o el primer campo con un error
- Usa
setFocus(errorKeys[0])de RHF para mover el focus al primer campo inválido - Combina con un resumen de errores que usa enlaces de anclaje para que los usuarios puedan saltar a campos específicos
Relacionado
- Mostrar errores de formulario — patrones visuales de error
- Patrones de formulario básicos — patrones de formulario accesibles
- shadcn Form — atributos ARIA auto-conectados
- Formularios optimistas — anunciando cambios de estado optimista