Form Accessibility
ARIA attributes, focus management, and error announcements — make every form usable by everyone.
Recipe
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.
Working Example
"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:
- Error summary with anchor links to invalid fields
aria-invalid,aria-describedby,aria-requiredon every fieldrole="alert"for error messages (immediate announcement)aria-live="polite"region for success announcements- Focus management — error summary and first error field receive focus
sr-onlytext for screen-reader-only contentnoValidateto disable browser validation and use custom messagesautoCompletefor email fields
Deep Dive
How It Works
aria-invalid="true"tells assistive tech the field has an erroraria-describedbylinks 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+disableddisables all inputs inside during submission- The error summary with
<a href="#fieldId">lets users jump to the problematic field
Variations
Auto-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>
);
}TypeScript Notes
// 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 errorGotchas
-
Too many
role="alert"elements — Each one announces immediately, overwhelming the user. Fix: Use one error summary withrole="alert"and individual errors without it, or render errors in sequence. -
aria-describedbywith 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 —
disabledremoves the button from the tab order. Fix: Usearia-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: AddnoValidateand handle all validation in JS with proper ARIA.
Alternatives
| 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 |
FAQs
What does aria-invalid do and when should you set it to true?
aria-invalid="true"tells assistive technology the field currently has an error- Set it when the field has a validation error:
aria-invalid={!!errors.fieldName} - Screen readers announce the invalid state when the user focuses the field
Why use aria-describedby with space-separated IDs?
aria-describedbylinks an input to one or more elements that describe it- Space-separated IDs let you combine description and error:
aria-describedby="email-desc email-error" - Only include IDs of elements that are currently rendered in the DOM
What is the difference between role="alert" and aria-live="polite"?
role="alert"announces content immediately and interrupts the current speecharia-live="polite"waits until the screen reader finishes the current announcement- Use
role="alert"for errors; usearia-live="polite"for success/status messages
Why does the error summary use tabIndex={-1}?
tabIndex={-1}makes the element focusable via JavaScript.focus()but keeps it out of the normal tab order- This lets you programmatically move focus to the error summary after a failed submission
- Without it, calling
.focus()on a<div>would do nothing
Gotcha: What happens if you use too many role="alert" elements on a form?
- Each
role="alert"element announces immediately when it appears - Multiple simultaneous announcements overwhelm screen reader users
- Fix: use a single error summary with
role="alert"and omit the role on individual field errors
Gotcha: Why should you avoid disabling the submit button with the disabled attribute?
disabledremoves the button from the tab order entirely- Keyboard users cannot reach or interact with it
- Use
aria-disabled="true"with a click handler that prevents submission instead, keeping the button focusable
Why add noValidate to the form element?
- Browser-native validation popups are inaccessible and visually inconsistent across browsers
noValidatedisables them so you can handle all validation in JavaScript with proper ARIA attributes- You maintain full control over error messages and focus management
How does the sr-only class help with required field indicators?
- The visual asterisk (
*) usesaria-hidden="true"so screen readers ignore it - A separate
<span className="sr-only">(required)</span>provides the text equivalent - This ensures both sighted users and screen reader users understand the field is required
How do you type-check aria attributes in TypeScript?
const ariaProps = {
"aria-invalid": !!error as boolean,
"aria-describedby": error ? `${id}-error` : undefined,
"aria-required": required || undefined,
} satisfies React.AriaAttributes;- Use
satisfies React.AriaAttributesto ensure only valid ARIA props are included
How does setFocus from react-hook-form provide type safety?
const { setFocus } = useForm<FormData>();
setFocus("email"); // OK — "email" is a key of FormData
setFocus("typo"); // TS error — "typo" is not a keysetFocusaccepts only field names from the form's generic type parameter
What is the purpose of the fieldset element with disabled in the working example?
- Wrapping inputs in
<fieldset disabled={isSubmitting}>disables all child inputs at once during submission - This prevents double-submission without disabling each input individually
- It also provides a semantic grouping with the
<legend>for screen readers
How should you handle focus management after a failed form submission?
- Focus the error summary or the first field with an error
- Use
setFocus(errorKeys[0])from RHF to move focus to the first invalid field - Combine with an error summary that uses anchor links so users can jump to specific fields
Related
- Form Error Display — visual error patterns
- Form Patterns Basic — accessible form patterns
- shadcn Form — auto-wired ARIA attributes
- Optimistic Forms — announcing optimistic state changes