DEV Community

Wilson Xu
Wilson Xu

Posted on

Building Accessible React Forms Without a Library

Building Accessible React Forms Without a Library

By Wilson Xu — 2,700 words


Every React form library — React Hook Form, Formik, Zod-validated controllers — handles validation logic. None of them guarantee accessibility. You can have perfectly validated, zero-dependency forms that are completely unusable by screen reader users, keyboard-only navigators, and people with motor disabilities.

This article is about building forms that work for everyone, from scratch, without pulling in @radix-ui/react-form or downshift. We'll cover ARIA attributes, focus management, error announcements, keyboard navigation, and the patterns that make the difference between "it works" and "it works for all users."


Why Library Forms Aren't Enough

React Hook Form handles registration, validation, and submission. It does not:

  • Announce validation errors to screen readers
  • Move focus to the first error after submission
  • Associate error messages with their inputs via aria-describedby
  • Manage aria-invalid states correctly
  • Handle dynamic field groups (add/remove rows) accessibly

You have to do all of that yourself. The good news: it's not that much code.


The Accessible Form Foundation

Every form field needs four things:

  1. A <label> with a for/htmlFor pointing to the input's id
  2. An error message element with a stable id
  3. aria-describedby on the input pointing to the error element's id
  4. aria-invalid="true" when the field has an error
// components/FormField.tsx
interface FormFieldProps {
  id: string
  label: string
  error?: string
  required?: boolean
  children: (props: {
    id: string
    'aria-describedby': string
    'aria-invalid': boolean
    'aria-required': boolean
  }) => React.ReactNode
}

export function FormField({ id, label, error, required, children }: FormFieldProps) {
  const errorId = `${id}-error`
  const descriptionId = `${id}-description`

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && (
          <span aria-hidden="true" className="required-indicator"> *</span>
        )}
      </label>

      {children({
        id,
        'aria-describedby': error ? errorId : descriptionId,
        'aria-invalid': !!error,
        'aria-required': !!required,
      })}

      {/* Error message: always in DOM, visible only when needed */}
      <span
        id={errorId}
        role="alert"
        aria-live="polite"
        className={`form-error ${error ? 'visible' : 'hidden'}`}
      >
        {error ?? ''}
      </span>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Usage:

<FormField id="email" label="Email address" error={errors.email} required>
  {(fieldProps) => (
    <input
      type="email"
      {...fieldProps}
      value={email}
      onChange={e => setEmail(e.target.value)}
    />
  )}
</FormField>
Enter fullscreen mode Exit fullscreen mode

The render prop pattern lets FormField own the ARIA wiring while the caller controls the input type.


Error Announcements: Getting Screen Readers to Actually Speak

role="alert" announces content changes to screen readers automatically — but only when the content changes. If you toggle visibility with CSS while leaving the text in the DOM, some screen readers won't announce it. The safest approach:

// Always render the error container. Change its text content, not its visibility.
// role="alert" + aria-live="polite" = announced immediately but not interruptive

<span
  id={`${id}-error`}
  role="alert"
  aria-live="polite"
>
  {error}  {/* When this text appears/changes, screen readers announce it */}
</span>
Enter fullscreen mode Exit fullscreen mode

For more urgent errors (e.g., security alerts), use aria-live="assertive" — it interrupts the current speech. For form validation, polite is almost always correct.

The Hidden Trap: Multiple Errors on Submit

When a user submits an invalid form, you might set 5 errors at once. With aria-live regions on each field, the screen reader gets 5 simultaneous announcements and reads only one (or none).

The fix: a single form-level live region that summarizes all errors.

// components/ErrorSummary.tsx
interface ErrorSummaryProps {
  errors: Record<string, string>
  headingRef?: React.RefObject<HTMLHeadingElement>
}

export function ErrorSummary({ errors, headingRef }: ErrorSummaryProps) {
  const errorEntries = Object.entries(errors).filter(([, msg]) => msg)

  if (errorEntries.length === 0) return null

  return (
    <div
      role="alert"
      aria-labelledby="error-summary-heading"
      className="error-summary"
    >
      <h2 id="error-summary-heading" ref={headingRef} tabIndex={-1}>
        {errorEntries.length === 1
          ? 'There is 1 error in this form'
          : `There are ${errorEntries.length} errors in this form`}
      </h2>
      <ul>
        {errorEntries.map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`}>{message}</a>
          </li>
        ))}
      </ul>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Each error in the summary links to the relevant field. When the user clicks a link, focus moves directly to the problematic input.


Focus Management: Where Focus Goes Matters

After form submission with errors, sighted users see the error highlights. Screen reader users need focus to move to the right place.

// hooks/useFormSubmit.ts
import { useRef, useCallback } from 'react'

export function useFormSubmit<T extends Record<string, string>>(
  validate: (values: Record<string, string>) => T,
  onSubmit: (values: Record<string, string>) => Promise<void>
) {
  const errorSummaryRef = useRef<HTMLHeadingElement>(null)
  const [errors, setErrors] = useState<T>({} as T)
  const [isSubmitting, setIsSubmitting] = useState(false)

  const handleSubmit = useCallback(
    async (e: React.FormEvent, values: Record<string, string>) => {
      e.preventDefault()

      const validationErrors = validate(values)

      if (Object.keys(validationErrors).length > 0) {
        setErrors(validationErrors)

        // Move focus to error summary after state update
        requestAnimationFrame(() => {
          errorSummaryRef.current?.focus()
        })
        return
      }

      setIsSubmitting(true)
      try {
        await onSubmit(values)
      } finally {
        setIsSubmitting(false)
      }
    },
    [validate, onSubmit]
  )

  return { errors, isSubmitting, handleSubmit, errorSummaryRef }
}
Enter fullscreen mode Exit fullscreen mode

requestAnimationFrame ensures React has committed the error summary to the DOM before we try to focus it. This is the correct pattern — useEffect with a dependency on errors also works but is slightly less predictable.

Focus Trapping in Modals and Dialogs

If your form is inside a modal, you must trap focus within it. Clicking outside should not allow Tab to escape to the document behind.

// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react'

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(', ')

export function useFocusTrap(active: boolean) {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!active) return

    const container = containerRef.current
    if (!container) return

    // Focus first focusable element
    const focusables = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
    focusables[0]?.focus()

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return

      const focusable = Array.from(
        container!.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
      ).filter(el => !el.closest('[aria-hidden="true"]'))

      const first = focusable[0]
      const last = focusable[focusable.length - 1]

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault()
        last.focus()
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault()
        first.focus()
      }
    }

    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [active])

  return containerRef
}
Enter fullscreen mode Exit fullscreen mode

Usage:

function FormModal({ isOpen, onClose }) {
  const trapRef = useFocusTrap(isOpen)

  return isOpen ? (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      ref={trapRef}
    >
      <h2 id="dialog-title">Edit Profile</h2>
      <ProfileForm />
      <button onClick={onClose}>Cancel</button>
    </div>
  ) : null
}
Enter fullscreen mode Exit fullscreen mode

Keyboard Navigation: Beyond Tab Order

Tab order follows DOM order. If your visual layout reorders elements with CSS flexbox or grid, your tab order may be confusing. Never use tabindex values greater than 0 — they break natural tab order globally.

Composite Widgets: Radio Groups and Select Lists

For custom radio groups, manage focus within the group with arrow keys — this is the expected keyboard pattern per ARIA Authoring Practices.

// components/RadioGroup.tsx
'use client'
import { useRef, KeyboardEvent } from 'react'

interface RadioOption {
  value: string
  label: string
}

interface RadioGroupProps {
  name: string
  legend: string
  options: RadioOption[]
  value: string
  onChange: (value: string) => void
  error?: string
}

export function RadioGroup({ name, legend, options, value, onChange, error }: RadioGroupProps) {
  const groupRef = useRef<HTMLFieldSetElement>(null)
  const errorId = `${name}-error`

  function handleKeyDown(e: KeyboardEvent, index: number) {
    const total = options.length
    let nextIndex = index

    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
      e.preventDefault()
      nextIndex = (index + 1) % total
    } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
      e.preventDefault()
      nextIndex = (index - 1 + total) % total
    } else {
      return
    }

    const radios = groupRef.current?.querySelectorAll<HTMLInputElement>('input[type="radio"]')
    radios?.[nextIndex]?.focus()
    onChange(options[nextIndex].value)
  }

  return (
    <fieldset ref={groupRef} aria-describedby={error ? errorId : undefined}>
      <legend>{legend}</legend>

      {options.map((option, index) => (
        <label key={option.value} className="radio-label">
          <input
            type="radio"
            name={name}
            value={option.value}
            checked={value === option.value}
            onChange={() => onChange(option.value)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            tabIndex={value === option.value ? 0 : -1}
            aria-invalid={!!error}
          />
          {option.label}
        </label>
      ))}

      {error && (
        <span id={errorId} role="alert" className="form-error">
          {error}
        </span>
      )}
    </fieldset>
  )
}
Enter fullscreen mode Exit fullscreen mode

The tabIndex={value === option.value ? 0 : -1} pattern is "roving tabindex" — only the selected radio is in the tab sequence. Arrow keys move between options.


Dynamic Fields: Accessible Add/Remove Patterns

Forms with "Add another" rows are notoriously tricky.

// components/DynamicList.tsx
'use client'
import { useRef } from 'react'

export function DynamicEmailList() {
  const [emails, setEmails] = useState([''])
  const addButtonRef = useRef<HTMLButtonElement>(null)
  const lastInputRef = useRef<HTMLInputElement>(null)

  function addEmail() {
    setEmails(prev => [...prev, ''])
    // Focus the new input after render
    requestAnimationFrame(() => {
      lastInputRef.current?.focus()
    })
  }

  function removeEmail(index: number) {
    setEmails(prev => prev.filter((_, i) => i !== index))
    // Return focus to "Add" button when last item removed, or previous item
    requestAnimationFrame(() => {
      addButtonRef.current?.focus()
    })
  }

  return (
    <fieldset>
      <legend>Email addresses</legend>

      {emails.map((email, index) => (
        <div key={index} className="dynamic-row">
          <label htmlFor={`email-${index}`}>
            Email {index + 1}
          </label>
          <input
            id={`email-${index}`}
            type="email"
            value={email}
            ref={index === emails.length - 1 ? lastInputRef : undefined}
            onChange={e => {
              const next = [...emails]
              next[index] = e.target.value
              setEmails(next)
            }}
            aria-label={`Email address ${index + 1} of ${emails.length}`}
          />
          {emails.length > 1 && (
            <button
              type="button"
              onClick={() => removeEmail(index)}
              aria-label={`Remove email ${index + 1}: ${email || 'empty'}`}
            >
              Remove
            </button>
          )}
        </div>
      ))}

      <button
        type="button"
        ref={addButtonRef}
        onClick={addEmail}
        aria-label={`Add another email address (${emails.length} currently)`}
      >
        + Add email
      </button>
    </fieldset>
  )
}
Enter fullscreen mode Exit fullscreen mode

The aria-label on the remove button is critical. "Remove" alone is ambiguous — "Remove email 2: user@example.com" is unambiguous.


Putting It All Together: A Complete Accessible Form

// components/ContactForm.tsx
'use client'
import { useState, useRef } from 'react'
import { FormField } from './FormField'
import { ErrorSummary } from './ErrorSummary'

interface FormValues {
  name: string
  email: string
  subject: string
  message: string
}

interface FormErrors {
  name?: string
  email?: string
  subject?: string
  message?: string
}

function validate(values: FormValues): FormErrors {
  const errors: FormErrors = {}
  if (!values.name.trim()) errors.name = 'Name is required'
  if (!values.email.trim()) errors.email = 'Email is required'
  else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email))
    errors.email = 'Enter a valid email address'
  if (!values.subject) errors.subject = 'Please select a subject'
  if (values.message.trim().length < 10)
    errors.message = 'Message must be at least 10 characters'
  return errors
}

export function ContactForm() {
  const [values, setValues] = useState<FormValues>({
    name: '', email: '', subject: '', message: '',
  })
  const [errors, setErrors] = useState<FormErrors>({})
  const [submitted, setSubmitted] = useState(false)
  const errorSummaryRef = useRef<HTMLHeadingElement>(null)

  function update(field: keyof FormValues) {
    return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
      setValues(prev => ({ ...prev, [field]: e.target.value }))
      // Clear error on change
      if (errors[field]) setErrors(prev => ({ ...prev, [field]: undefined }))
    }
  }

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const validationErrors = validate(values)

    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors)
      requestAnimationFrame(() => errorSummaryRef.current?.focus())
      return
    }

    // Submit...
    setSubmitted(true)
  }

  if (submitted) {
    return (
      <div role="status" aria-live="polite">
        <h2>Message sent successfully!</h2>
        <p>We'll be in touch within 2 business days.</p>
      </div>
    )
  }

  return (
    <form onSubmit={handleSubmit} noValidate aria-label="Contact form">
      <ErrorSummary errors={errors} headingRef={errorSummaryRef} />

      <FormField id="name" label="Full name" error={errors.name} required>
        {(p) => <input type="text" {...p} value={values.name} onChange={update('name')} autoComplete="name" />}
      </FormField>

      <FormField id="email" label="Email address" error={errors.email} required>
        {(p) => <input type="email" {...p} value={values.email} onChange={update('email')} autoComplete="email" />}
      </FormField>

      <FormField id="subject" label="Subject" error={errors.subject} required>
        {(p) => (
          <select {...p} value={values.subject} onChange={update('subject')}>
            <option value="">Select a subject</option>
            <option value="general">General enquiry</option>
            <option value="support">Technical support</option>
            <option value="billing">Billing</option>
          </select>
        )}
      </FormField>

      <FormField id="message" label="Message" error={errors.message} required>
        {(p) => (
          <textarea
            {...p}
            value={values.message}
            onChange={update('message')}
            rows={5}
            aria-describedby={`${p['aria-describedby']} message-hint`}
          />
        )}
      </FormField>
      <span id="message-hint" className="field-hint">Minimum 10 characters</span>

      <button type="submit">Send message</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Checklist Before Shipping

  • [ ] Every input has a visible, programmatically associated <label>
  • [ ] Errors are announced via aria-live regions or role="alert"
  • [ ] Error summary with focus management on failed submission
  • [ ] aria-invalid="true" set on invalid fields
  • [ ] aria-describedby links inputs to their error messages
  • [ ] Custom widgets (radio groups, dropdowns) implement keyboard patterns from ARIA APG
  • [ ] Dynamic add/remove fields move focus sensibly
  • [ ] Form works completely with keyboard alone (no mouse required)
  • [ ] Tested with VoiceOver (macOS/iOS) and NVDA (Windows)
  • [ ] noValidate on <form> — use your own validation, not browser bubbles

Accessibility isn't a feature you add at the end. These patterns take minutes to implement when you build them in from the start. Your users who depend on them don't get a "we'll add that later" — they just can't use your product.


Wilson Xu writes about frontend engineering and accessibility. He contributes to open-source tooling at github.com/chengyixu.

Top comments (0)