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>withhtmlFor - Error messages use
role="alert"for screen reader announcements aria-invalidmarks fields with errorsaria-describedbylinks error and helper textaria-requiredindicates 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.