Next.js Form Accessibility
Building accessible forms in your Next.js applications is not just a best practice—it's essential for creating inclusive web experiences that work for everyone, including users with disabilities who may rely on assistive technologies like screen readers.
Introduction to Form Accessibility
Accessibility (often abbreviated as a11y) ensures that your web applications can be used by people with various disabilities, including visual, motor, auditory, and cognitive impairments. Forms are critical interaction points in web applications, and making them accessible can significantly improve user experience for everyone.
In this guide, we'll explore how to implement accessible forms in Next.js applications, covering:
- Proper labeling and structure
- Keyboard navigation
- Error handling and validation
- ARIA attributes
- Focus management
Proper Form Structure and Labeling
Using Semantic HTML
Always start with proper semantic HTML. This provides a strong foundation for accessibility.
// Bad example - not accessible
<div>
Name
<input type="text" onChange={handleChange} />
</div>
// Good example - properly labeled
<div>
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
onChange={handleChange}
/>
</div>
Form Groups and Fieldsets
Use fieldset
and legend
elements to group related form controls:
<form>
<fieldset>
<legend>Personal Information</legend>
<div>
<label htmlFor="firstName">First Name</label>
<input id="firstName" type="text" />
</div>
<div>
<label htmlFor="lastName">Last Name</label>
<input id="lastName" type="text" />
</div>
</fieldset>
</form>
Keyboard Navigation
Ensure all form elements are keyboard accessible. Users should be able to navigate through your form using the Tab key, and interact with controls using Space, Enter, and arrow keys.
Tab Order
Form elements should have a logical tab order. This usually means arranging your form fields in the visual order that users would naturally follow.
export default function ContactForm() {
// Logical tab order: name -> email -> message -> submit
return (
<form>
<div>
<label htmlFor="name">Name</label>
<input id="name" type="text" />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message"></textarea>
</div>
<button type="submit">Send Message</button>
</form>
);
}
Custom Controls
When building custom form controls, ensure they're fully keyboard accessible:
export function CustomCheckbox({ id, label, checked, onChange }) {
return (
<div className="custom-checkbox">
<input
type="checkbox"
id={id}
checked={checked}
onChange={onChange}
// Hidden visually but available to screen readers
className="sr-only"
/>
<label htmlFor={id}>
<div
className={`checkbox-indicator ${checked ? 'checked' : ''}`}
role="presentation"
></div>
<span>{label}</span>
</label>
</div>
);
}
Error Handling and Validation
Provide clear error messages and make them accessible to all users.
Client-Side Validation
import { useState } from 'react';
export default function SignupForm() {
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const validateEmail = (value) => {
if (!value) {
setEmailError('Email is required');
return false;
} else if (!/\S+@\S+\.\S+/.test(value)) {
setEmailError('Please enter a valid email address');
return false;
}
setEmailError('');
return true;
};
const handleSubmit = (e) => {
e.preventDefault();
const isEmailValid = validateEmail(email);
if (isEmailValid) {
// Process form submission
console.log('Form submitted successfully');
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={emailError ? 'true' : 'false'}
aria-describedby={emailError ? 'email-error' : undefined}
/>
{emailError && (
<div id="email-error" className="error" role="alert">
{emailError}
</div>
)}
</div>
<button type="submit">Sign Up</button>
</form>
);
}
Using aria-live for Dynamic Updates
When errors are displayed dynamically, use ARIA live regions to alert screen readers:
<div className="form-errors" aria-live="polite">
{formErrors.map((error, index) => (
<p key={index} className="error">{error}</p>
))}
</div>
ARIA Attributes for Enhanced Accessibility
ARIA (Accessible Rich Internet Applications) attributes provide additional semantic information for assistive technologies.
Common ARIA Attributes for Forms
<form>
{/* Required fields */}
<div>
<label htmlFor="username">
Username <span aria-hidden="true">*</span>
</label>
<input
id="username"
type="text"
required
aria-required="true"
/>
</div>
{/* Input with description */}
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint">
Must be at least 8 characters with one number and one special character.
</p>
</div>
{/* Expanded sections */}
<button
aria-expanded={isExpanded ? 'true' : 'false'}
onClick={() => setIsExpanded(!isExpanded)}
>
Advanced Options
</button>
</form>
Focus Management
Properly managing focus enhances the keyboard navigation experience and helps users understand where they are in the form.
Focus on Initial Load
import { useEffect, useRef } from 'react';
export default function SearchForm() {
const inputRef = useRef(null);
useEffect(() => {
// Focus the search input when component mounts
inputRef.current.focus();
}, []);
return (
<form>
<label htmlFor="search">Search</label>
<input id="search" type="search" ref={inputRef} />
<button type="submit">Search</button>
</form>
);
}
Focus Management After Form Submission
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const confirmationRef = useRef(null);
const handleSubmit = async (e) => {
e.preventDefault();
// Process form submission
await submitForm(formData);
// Show confirmation and move focus to it
setSubmitted(true);
// Use setTimeout to allow rendering before focusing
setTimeout(() => {
if (confirmationRef.current) {
confirmationRef.current.focus();
}
}, 0);
};
return (
<div>
{!submitted ? (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit">Submit</button>
</form>
) : (
<div
ref={confirmationRef}
tabIndex={-1}
className="confirmation"
>
Thank you for your submission!
</div>
)}
</div>
);
}
Real-World Example: Accessible Checkout Form
Let's bring everything together in a practical example of an accessible checkout form:
import { useState, useRef } from 'react';
export default function CheckoutForm() {
const [formData, setFormData] = useState({
fullName: '',
email: '',
address: '',
city: '',
zipCode: '',
cardNumber: '',
cardExpiry: '',
cardCvc: ''
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const confirmationRef = useRef(null);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.fullName.trim()) {
newErrors.fullName = 'Full name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Email is invalid';
}
// Add more validations as needed
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1500));
// Success handling
setIsSubmitted(true);
// Focus on confirmation message
setTimeout(() => {
if (confirmationRef.current) {
confirmationRef.current.focus();
}
}, 0);
} catch (error) {
console.error('Submission error:', error);
} finally {
setIsSubmitting(false);
}
};
if (isSubmitted) {
return (
<div
className="confirmation-message"
ref={confirmationRef}
tabIndex={-1}
role="alert"
>
<h2>Thank you for your order!</h2>
<p>Your order has been placed successfully.</p>
</div>
);
}
return (
<div className="checkout-container">
<h1>Checkout</h1>
<form onSubmit={handleSubmit} noValidate>
<div className="form-group">
<fieldset>
<legend>Personal Information</legend>
<div className="form-field">
<label htmlFor="fullName">
Full Name <span aria-hidden="true">*</span>
</label>
<input
id="fullName"
name="fullName"
type="text"
value={formData.fullName}
onChange={handleChange}
aria-required="true"
aria-invalid={!!errors.fullName}
aria-describedby={errors.fullName ? 'fullName-error' : undefined}
/>
{errors.fullName && (
<div id="fullName-error" className="error" role="alert">
{errors.fullName}
</div>
)}
</div>
<div className="form-field">
<label htmlFor="email">
Email Address <span aria-hidden="true">*</span>
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<div id="email-error" className="error" role="alert">
{errors.email}
</div>
)}
</div>
</fieldset>
{/* Additional form sections would go here */}
</div>
<div className="form-actions">
<button
type="submit"
disabled={isSubmitting}
aria-busy={isSubmitting}
>
{isSubmitting ? 'Processing...' : 'Place Order'}
</button>
</div>
</form>
</div>
);
}
Testing Form Accessibility
To ensure your forms meet accessibility standards, use these testing methods:
- Keyboard Testing: Navigate your form using only the keyboard.
- Screen Reader Testing: Test your form with screen readers like NVDA, JAWS, or VoiceOver.
- Automated Tools: Use tools like Lighthouse, axe, or Wave to catch common accessibility issues.
- Contrast Testing: Check that form elements have sufficient color contrast.
Summary
Building accessible forms in Next.js requires attention to several key areas:
- Using proper semantic HTML with labels correctly associated with inputs
- Ensuring keyboard navigation works smoothly
- Providing clear error messages and validation feedback
- Using ARIA attributes to enhance semantics
- Managing focus appropriately
- Testing with assistive technologies
By following these practices, you'll create forms that work well for all users, including those with disabilities, improving the overall user experience of your Next.js applications.
Additional Resources
- Web Content Accessibility Guidelines (WCAG)
- MDN Web Docs: Form Accessibility
- A11y Project: Accessible Form Controls
- React Aria - A library of React hooks for accessible UI components
Practice Exercises
- Take an existing form in your application and improve its accessibility using the techniques from this guide.
- Build an accessible multi-step form with focus management between steps.
- Create a custom form control (like a date picker or autocomplete) with full keyboard and screen reader support.
- Audit your form using the axe DevTools browser extension and fix any issues it identifies.
By consistently applying these accessibility practices, you'll create Next.js forms that are usable by everyone, regardless of their abilities or the assistive technologies they use.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)