Skip to main content
Back to Blog
Tutorials
4 min read
November 24, 2024

How to Build Accessible Form Components in React

Create accessible form components with proper labels, error messages, keyboard navigation, and screen reader support.

Ryel Banfield

Founder & Lead Developer

Accessible forms ensure every user can interact with your application. Here is how to build them correctly.

Step 1: Accessible Text Input

"use client";

import { forwardRef, useId } from "react";

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  helperText?: string;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, helperText, required, className, ...props }, ref) => {
    const id = useId();
    const errorId = `${id}-error`;
    const helperId = `${id}-helper`;

    const describedBy = [
      error ? errorId : null,
      helperText ? helperId : null,
    ]
      .filter(Boolean)
      .join(" ");

    return (
      <div className="space-y-1.5">
        <label
          htmlFor={id}
          className="block text-sm font-medium text-gray-700 dark:text-gray-300"
        >
          {label}
          {required && (
            <span className="ml-1 text-red-500" aria-hidden="true">
              *
            </span>
          )}
        </label>

        <input
          ref={ref}
          id={id}
          required={required}
          aria-invalid={error ? "true" : undefined}
          aria-describedby={describedBy || undefined}
          aria-required={required}
          className={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            error
              ? "border-red-500 focus:ring-red-500"
              : "border-gray-300 dark:border-gray-700"
          } dark:bg-gray-800 ${className ?? ""}`}
          {...props}
        />

        {helperText && !error && (
          <p id={helperId} className="text-xs text-gray-500">
            {helperText}
          </p>
        )}

        {error && (
          <p id={errorId} className="text-xs text-red-600" role="alert">
            {error}
          </p>
        )}
      </div>
    );
  }
);

Input.displayName = "Input";

Step 2: Accessible Select

"use client";

import { forwardRef, useId } from "react";

interface SelectOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
  label: string;
  options: SelectOption[];
  error?: string;
  placeholder?: string;
}

export const Select = forwardRef<HTMLSelectElement, SelectProps>(
  ({ label, options, error, placeholder, required, ...props }, ref) => {
    const id = useId();
    const errorId = `${id}-error`;

    return (
      <div className="space-y-1.5">
        <label
          htmlFor={id}
          className="block text-sm font-medium text-gray-700 dark:text-gray-300"
        >
          {label}
          {required && (
            <span className="ml-1 text-red-500" aria-hidden="true">
              *
            </span>
          )}
        </label>

        <select
          ref={ref}
          id={id}
          required={required}
          aria-invalid={error ? "true" : undefined}
          aria-describedby={error ? errorId : undefined}
          className={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            error ? "border-red-500" : "border-gray-300 dark:border-gray-700"
          } dark:bg-gray-800`}
          {...props}
        >
          {placeholder && (
            <option value="" disabled>
              {placeholder}
            </option>
          )}
          {options.map((opt) => (
            <option key={opt.value} value={opt.value} disabled={opt.disabled}>
              {opt.label}
            </option>
          ))}
        </select>

        {error && (
          <p id={errorId} className="text-xs text-red-600" role="alert">
            {error}
          </p>
        )}
      </div>
    );
  }
);

Select.displayName = "Select";

Step 3: Accessible Checkbox and Radio Group

"use client";

import { useId } from "react";

interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
  label: string;
  description?: string;
}

export function Checkbox({ label, description, ...props }: CheckboxProps) {
  const id = useId();
  const descId = `${id}-desc`;

  return (
    <div className="flex items-start gap-3">
      <input
        type="checkbox"
        id={id}
        aria-describedby={description ? descId : undefined}
        className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
        {...props}
      />
      <div>
        <label htmlFor={id} className="text-sm font-medium cursor-pointer">
          {label}
        </label>
        {description && (
          <p id={descId} className="text-xs text-gray-500">
            {description}
          </p>
        )}
      </div>
    </div>
  );
}

interface RadioGroupProps {
  label: string;
  name: string;
  options: { value: string; label: string; description?: string }[];
  value: string;
  onChange: (value: string) => void;
  error?: string;
  required?: boolean;
}

export function RadioGroup({
  label,
  name,
  options,
  value,
  onChange,
  error,
  required,
}: RadioGroupProps) {
  const groupId = useId();
  const errorId = `${groupId}-error`;

  return (
    <fieldset
      aria-describedby={error ? errorId : undefined}
      aria-required={required}
    >
      <legend className="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
        {label}
        {required && (
          <span className="ml-1 text-red-500" aria-hidden="true">
            *
          </span>
        )}
      </legend>

      <div className="space-y-2" role="radiogroup">
        {options.map((option) => {
          const optionId = `${groupId}-${option.value}`;
          return (
            <label
              key={option.value}
              htmlFor={optionId}
              className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors ${
                value === option.value
                  ? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
                  : "border-gray-200 hover:border-gray-300 dark:border-gray-700"
              }`}
            >
              <input
                type="radio"
                id={optionId}
                name={name}
                value={option.value}
                checked={value === option.value}
                onChange={() => onChange(option.value)}
                className="mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
              />
              <div>
                <p className="text-sm font-medium">{option.label}</p>
                {option.description && (
                  <p className="text-xs text-gray-500">{option.description}</p>
                )}
              </div>
            </label>
          );
        })}
      </div>

      {error && (
        <p id={errorId} className="mt-1.5 text-xs text-red-600" role="alert">
          {error}
        </p>
      )}
    </fieldset>
  );
}

Step 4: Accessible Textarea

"use client";

import { forwardRef, useId } from "react";

interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
  label: string;
  error?: string;
  helperText?: string;
  maxLength?: number;
  currentLength?: number;
}

export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ label, error, helperText, maxLength, currentLength, required, ...props }, ref) => {
    const id = useId();
    const errorId = `${id}-error`;
    const helperId = `${id}-helper`;

    return (
      <div className="space-y-1.5">
        <div className="flex items-center justify-between">
          <label
            htmlFor={id}
            className="block text-sm font-medium text-gray-700 dark:text-gray-300"
          >
            {label}
            {required && <span className="ml-1 text-red-500" aria-hidden="true">*</span>}
          </label>
          {maxLength !== undefined && currentLength !== undefined && (
            <span
              className={`text-xs ${
                currentLength > maxLength ? "text-red-500" : "text-gray-400"
              }`}
              aria-live="polite"
            >
              {currentLength}/{maxLength}
            </span>
          )}
        </div>

        <textarea
          ref={ref}
          id={id}
          required={required}
          aria-invalid={error ? "true" : undefined}
          aria-describedby={
            [error ? errorId : null, helperText ? helperId : null]
              .filter(Boolean)
              .join(" ") || undefined
          }
          className={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
            error ? "border-red-500" : "border-gray-300 dark:border-gray-700"
          } dark:bg-gray-800`}
          {...props}
        />

        {helperText && !error && (
          <p id={helperId} className="text-xs text-gray-500">{helperText}</p>
        )}
        {error && (
          <p id={errorId} className="text-xs text-red-600" role="alert">{error}</p>
        )}
      </div>
    );
  }
);

Textarea.displayName = "Textarea";

Step 5: Form Error Summary

"use client";

import { useEffect, useRef } from "react";
import { AlertCircle } from "lucide-react";

interface FormErrorSummaryProps {
  errors: Record<string, string>;
  fieldLabels: Record<string, string>;
}

export function FormErrorSummary({ errors, fieldLabels }: FormErrorSummaryProps) {
  const ref = useRef<HTMLDivElement>(null);
  const errorEntries = Object.entries(errors);

  useEffect(() => {
    if (errorEntries.length > 0) {
      ref.current?.focus();
    }
  }, [errorEntries.length]);

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

  return (
    <div
      ref={ref}
      tabIndex={-1}
      role="alert"
      aria-label={`${errorEntries.length} errors in form`}
      className="rounded-lg border border-red-200 bg-red-50 p-4 focus:outline-none focus:ring-2 focus:ring-red-500 dark:border-red-800 dark:bg-red-950/20"
    >
      <div className="flex items-center gap-2">
        <AlertCircle className="h-4 w-4 text-red-600" />
        <h2 className="text-sm font-semibold text-red-800 dark:text-red-400">
          Please fix {errorEntries.length} error{errorEntries.length > 1 ? "s" : ""}
        </h2>
      </div>
      <ul className="mt-2 list-inside list-disc space-y-1">
        {errorEntries.map(([field, message]) => (
          <li key={field} className="text-sm text-red-700 dark:text-red-400">
            <a
              href={`#${field}`}
              className="underline hover:no-underline"
              onClick={(e) => {
                e.preventDefault();
                document.getElementById(field)?.focus();
              }}
            >
              {fieldLabels[field] ?? field}
            </a>
            : {message}
          </li>
        ))}
      </ul>
    </div>
  );
}

Accessibility Checklist

  • Every input has an associated <label> with htmlFor
  • Error messages use role="alert" for screen reader announcements
  • aria-invalid marks fields with errors
  • aria-describedby links error and helper text
  • aria-required indicates required fields
  • Form error summary focuses on submission failure
  • Color is not the only indicator of errors (text messages included)
  • Interactive elements are keyboard accessible

Need Accessible Web Applications?

We build WCAG-compliant applications that work for all users. Contact us to discuss your project.

accessibilityformsa11yReactARIAtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles