Form Accessibility
ARIA attributes, focus management, and error announcements - make every form usable by everyone.
Search across all documentation pages
ARIA attributes, focus management, and error announcements - make every form usable by everyone.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// Accessible form field pattern
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"> (required)</span>}
</label>
<div
// Clone aria props onto children, or wrap input here
>
{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>
);
}
// Usage
<AccessibleField id="email" label="Email" error={errors.email} required>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby="email-desc email-error"
aria-required="true"
/>
</AccessibleField>When to reach for this: Every form. Accessibility is not optional - it is a requirement for any production application.
"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, "Name is required"),
email: z.string().email("Please enter a valid email"),
subject: z.enum(["general", "support", "billing"], {
required_error: "Please select a subject",
}),
message: z.string().min(20, "Message must be at least 20 characters"),
});
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",
});
// Focus first error field after failed submission
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("Your message has been sent successfully.");
reset();
}
const errorEntries = Object.entries(errors).filter(([k]) => k !== "root");
return (
<div className="max-w-md">
{/* Live region for status announcements */}
<div aria-live="polite" aria-atomic="true" className="sr-only">
{announced}
</div>
{/* Error summary - announced on appearance */}
{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">
There {errorEntries.length === 1 ? "is 1 error" : `are ${errorEntries.length} errors`} in your submission
</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">
Message sent successfully!
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} noValidate aria-label="Contact form">
<fieldset disabled={isSubmitting} className="space-y-4">
<legend className="sr-only">Contact information</legend>
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</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">
Email <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</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">
Subject <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</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="">-- Select --</option>
<option value="general">General Inquiry</option>
<option value="support">Support</option>
<option value="billing">Billing</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">
Message <span aria-hidden="true" className="text-red-500">*</span>
<span className="sr-only">(required)</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">
Minimum 20 characters
</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 ? "Sending..." : "Send Message"}
</button>
</fieldset>
</form>
</div>
);
}What this demonstrates:
aria-invalid, aria-describedby, aria-required on every fieldrole="alert" for error messages (immediate announcement)aria-live="polite" region for success announcementssr-only text for screen-reader-only contentnoValidate to disable browser validation and use custom messagesautoComplete for email fieldsaria-invalid="true" tells assistive tech the field has an erroraria-describedby links the input to its description and error elements (space-separated IDs)role="alert" creates an ARIA live region that announces content immediately when it appearsaria-live="polite" announces changes at the next available opportunity (non-interruptive)tabIndex={-1} makes an element focusable via JS (.focus()) but not in the tab orderfieldset + disabled disables all inputs inside during submission<a href="#fieldId"> lets users jump to the problematic fieldAuto-announcing form state changes:
function FormStatus({ isSubmitting, errorCount }: { isSubmitting: boolean; errorCount: number }) {
const message = isSubmitting
? "Submitting form..."
: errorCount > 0
? `Form has ${errorCount} ${errorCount === 1 ? "error" : "errors"}`
: "";
return (
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{message}
</div>
);
}Skip-to-errors link:
{errorEntries.length > 0 && (
<a href="#error-summary" className="sr-only focus:not-sr-only focus:absolute focus:p-2">
Skip to error summary
</a>
)}Character count with live feedback:
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} characters remaining
</p>
);
}// Type-safe aria attributes
const ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;
// setFocus accepts typed field names
const { setFocus } = useForm<FormData>();
setFocus("email"); // OK
setFocus("typo"); // TS errorToo many role="alert" elements - Each one announces immediately, overwhelming the user. Fix: Use one error summary with role="alert" and individual errors without it, or render errors in sequence.
aria-describedby with missing IDs - Referencing a non-existent ID is silently ignored but confusing. Fix: Only include IDs that are currently rendered.
Red-only error indication - Color alone fails WCAG. Fix: Combine color with text messages, icons, or border changes.
Disabling the submit button - disabled removes the button from the tab order. Fix: Use aria-disabled="true" with a click handler that prevents submission, keeping the button focusable.
Auto-focus on page load - Moving focus on load disorients screen reader users. Fix: Only move focus in response to user actions (like form submission).
Missing noValidate - Browser validation popups are inaccessible and inconsistent. Fix: Add noValidate and handle all validation in JS with proper ARIA.
| Alternative | Use When | Don't Use When |
|---|---|---|
| shadcn Form | It handles ARIA automatically via FormControl | You need custom ARIA behavior |
| react-aria (Adobe) | You want a headless library with full ARIA support | You already have accessible components |
| Radix UI primitives | You need accessible primitives (dialogs, selects, etc.) | Simple native inputs suffice |
| Native HTML validation | You want basic validation without JS | You need custom error messages or complex rules |
aria-invalid="true" tells assistive technology the field currently has an erroraria-invalid={!!errors.fieldName}aria-describedby links an input to one or more elements that describe itaria-describedby="email-desc email-error"role="alert" announces content immediately and interrupts the current speecharia-live="polite" waits until the screen reader finishes the current announcementrole="alert" for errors; use aria-live="polite" for success/status messagestabIndex={-1} makes the element focusable via JavaScript .focus() but keeps it out of the normal tab order.focus() on a <div> would do nothingrole="alert" element announces immediately when it appearsrole="alert" and omit the role on individual field errorsdisabled removes the button from the tab order entirelyaria-disabled="true" with a click handler that prevents submission instead, keeping the button focusablenoValidate disables them so you can handle all validation in JavaScript with proper ARIA attributes*) uses aria-hidden="true" so screen readers ignore it<span className="sr-only">(required)</span> provides the text equivalentconst ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;satisfies React.AriaAttributes to ensure only valid ARIA props are includedconst { setFocus } = useForm<FormData>();
setFocus("email"); // OK - "email" is a key of FormData
setFocus("typo"); // TS error - "typo" is not a keysetFocus accepts only field names from the form's generic type parameter<fieldset disabled={isSubmitting}> disables all child inputs at once during submission<legend> for screen readerssetFocus(errorKeys[0]) from RHF to move focus to the first invalid fieldReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥