React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

accessibilitya11yariafocus-managementformsscreen-reader

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-required on every field
  • role="alert" for error messages (immediate announcement)
  • aria-live="polite" region for success announcements
  • Focus management — error summary and first error field receive focus
  • sr-only text for screen-reader-only content
  • noValidate to disable browser validation and use custom messages
  • autoComplete for email fields

Deep Dive

How It Works

  • aria-invalid="true" tells assistive tech the field has an error
  • aria-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 appears
  • aria-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 order
  • fieldset + disabled disables 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 error

Gotchas

  • Too 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 buttondisabled 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.

Alternatives

AlternativeUse WhenDon't Use When
shadcn FormIt handles ARIA automatically via FormControlYou need custom ARIA behavior
react-aria (Adobe)You want a headless library with full ARIA supportYou already have accessible components
Radix UI primitivesYou need accessible primitives (dialogs, selects, etc.)Simple native inputs suffice
Native HTML validationYou want basic validation without JSYou 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-describedby links 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 speech
  • aria-live="polite" waits until the screen reader finishes the current announcement
  • Use role="alert" for errors; use aria-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?
  • disabled removes 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
  • noValidate disables 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 (*) uses aria-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.AriaAttributes to 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 key
  • setFocus accepts 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