Accesibilidad de formularios
Atributos ARIA, gestión del focus y anuncios de error - haz que cada formulario sea usable para todos.
Busca en todas las páginas de la documentación
Atributos ARIA, gestión del focus y anuncios de error - haz que cada formulario sea usable para todos.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
aria-invalid, aria-describedby, aria-required en cada camporole="alert" para mensajes de error (anuncio inmediato)aria-live="polite" para anuncios de éxito/estadosr-only para contenido solo para lectores de pantallanoValidate para deshabilitar la validación del navegador y usar mensajes personalizadosautoComplete para campos de correo electrónicoaria-invalid="true" le dice a la tecnología asistiva que el campo tiene un erroraria-describedby vincula 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 + disabled deshabilita todas las entradas dentro durante el envío<a href="#fieldId"> permite a los usuarios saltar al campo problemáticoAnuncios 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>
);
}// 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 TSDemasiados elementos role="alert" - Cada uno se anuncia inmediatamente, abrumando al usuario. Solución: Usa un resumen de errores con role="alert" y errores individuales sin él, o renderiza errores en secuencia.
aria-describedby con 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 - disabled elimina el botón del orden de tabulación. Solución: Usa aria-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ñade noValidate y maneja toda la validación en JS con ARIA adecuado.
| 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 |
aria-invalid="true" le dice a la tecnología asistiva que el campo actualmente tiene un erroraria-invalid={!!errors.fieldName}aria-describedby vincula una entrada a uno o más elementos que la describenaria-describedby="email-desc email-error"role="alert" anuncia contenido inmediatamente e interrumpe el anuncio actualaria-live="polite" espera hasta que el lector de pantalla termine el anuncio actualrole="alert" para errores; usa aria-live="polite" para mensajes de éxito/estadotabIndex={-1} hace que el elemento sea enfocable mediante JavaScript .focus() pero lo mantiene fuera del orden de tabulación normal.focus() en un <div> no haría nadarole="alert" se anuncia inmediatamente cuando aparecerole="alert" y omite el role en errores individuales de campodisabled elimina el botón del orden de tabulación completamentearia-disabled="true" con un manejador de clic que prevenga el envío en su lugar, manteniendo el botón enfocablenoValidate los deshabilita para que puedas manejar toda la validación en JavaScript con atributos ARIA adecuados*) usa aria-hidden="true" para que los lectores de pantalla lo ignoren<span className="sr-only">(requerido)</span> separado proporciona el equivalente de textoconst ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;satisfies React.AriaAttributes para asegurar que solo se incluyen propiedades ARIA válidasconst { setFocus } = useForm<FormData>();
setFocus("email"); // OK - "email" es una clave de FormData
setFocus("typo"); // Error TS - "typo" no es una clavesetFocus acepta solo nombres de campos del parámetro de tipo genérico del formulario<fieldset disabled={isSubmitting}> deshabilita todas las entradas hijas a la vez durante el envío<legend> para lectores de pantallasetFocus(errorKeys[0]) de RHF para mover el focus al primer campo inválidoRevisado por Chris St. John·Última actualización: 16 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥