Next.js course Β· Module 6: Forms and Server Actions
Usability and accessibility in forms
In this lesson7
Designing forms that are both user-friendly (usability) and accessible to people with various disabilities (accessibility) is a key aspect of building modern web applications. In this module we'll discuss how to create forms that are easy to use for all users, regardless of their abilities and the devices they use.
Why are usability and accessibility important?
Business benefits
- Increased conversion - forms that are easy to use have higher conversion rates
- Wider reach - accessible forms reach more users
- Legal compliance - in many countries website accessibility is required by law (e.g., ADA in the US, WCAG in the EU)
- Improved SEO - accessible pages often rank better in search results
Benefits for users
- Better performance - users can fill out forms faster and more easily
- Less frustration - well-designed forms reduce errors and frustration
- Greater independence - people with disabilities can use forms on their own
- Consistent experience - well-designed forms work consistently across various devices and browsers
Basic principles of form usability
1. Clear structure
- Group related fields - use
<fieldset>and<legend>to group fields - Logical order - organize fields in an intuitive order
- Appropriate labels - each field should have a clear label
2. Minimize cognitive load
- Ask only for necessary information - every additional field reduces conversion
- Break long forms into steps - multi-step forms are easier to process
- Show progress - in multi-step forms, inform the user of progress
3. Clear communication
- Clearly marked required fields - mark required fields consistently and visibly
- Precise labels - labels should clearly communicate what information is expected
- Hints and examples - for complex fields, add examples or hints
4. Effective data submission
- Real-time validation - inform about errors while filling out the form
- Preserve data on errors - don't reset the entire form after an error occurs
- Confirm actions - inform the user of the success or failure of form submission
Form accessibility principles
1. HTML structural principles
- Correctly associate labels with fields - use
<label for="id">linked to the field'sid - Group fields - use
<fieldset>and<legend>for groups of fields, especially checkboxes and radio buttons - Logical tab order - ensure the tab order is logical and intuitive
2. ARIA (Accessible Rich Internet Applications)
- aria-label and aria-labelledby attributes - for describing elements without visible labels
- aria-describedby - for linking additional descriptions with fields
- aria-required - for marking required fields
- aria-invalid - for marking fields with errors
- role="alert" - for error messages that should be read immediately
3. Contrast and readability
- Sufficient contrast - the contrast ratio between text and background should be at least 4.5:1
- Appropriate text size - text should be readable without zooming in
- Responsive design - forms should be usable on various screen sizes
4. Keyboard support
- Full keyboard support - all functions should be accessible via keyboard
- Visible focus - keyboard focus should be clearly visible
- Keyboard shortcuts - for advanced forms, keyboard shortcuts can be added
Implementing accessible forms in React
Basic accessible form
1import { useState, FormEvent, ChangeEvent } from 'react';
2
3export default function AccessibleForm() {
4 const [formData, setFormData] = useState({
5 name: '',
6 email: '',
7 phone: '',
8 message: '',
9 contactPreference: 'email',
10 newsletter: false
11 });
12
13 const [errors, setErrors] = useState<Record<string, string>>({});
14 const [isSubmitting, setIsSubmitting] = useState(false);
15 const [isSuccess, setIsSuccess] = useState(false);
16
17 const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
18 const { name, value, type } = e.target;
19 const checked = type === 'checkbox' ? (e.target as HTMLInputElement).checked : undefined;
20
21 setFormData(prev => ({
22 ...prev,
23 [name]: checked !== undefined ? checked : value
24 }));
25
26 // Remove error after editing the field
27 if (errors[name]) {
28 setErrors(prev => {
29 const newErrors = { ...prev };
30 delete newErrors[name];
31 return newErrors;
32 });
33 }
34 };
35
36 const validateForm = () => {
37 const newErrors: Record<string, string> = {};
38
39 if (!formData.name.trim()) {
40 newErrors.name = 'Full name is required';
41 }
42
43 if (!formData.email.trim()) {
44 newErrors.email = 'Email is required';
45 } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
46 newErrors.email = 'Please provide a valid email address';
47 }
48
49 if (formData.contactPreference === 'phone' && !formData.phone.trim()) {
50 newErrors.phone = 'Phone number is required when choosing phone contact';
51 }
52
53 if (!formData.message.trim()) {
54 newErrors.message = 'Message is required';
55 }
56
57 setErrors(newErrors);
58 return Object.keys(newErrors).length === 0;
59 };
60
61 const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
62 e.preventDefault();
63
64 if (!validateForm()) {
65 // Move focus to the first field with an error
66 const firstErrorField = document.getElementById(Object.keys(errors)[0]);
67 if (firstErrorField) {
68 firstErrorField.focus();
69 }
70 return;
71 }
72
73 setIsSubmitting(true);
74
75 try {
76 // Simulate form submission
77 await new Promise(resolve => setTimeout(resolve, 1500));
78 console.log('Form sent:', formData);
79
80 setIsSuccess(true);
81 // Move focus to the success message
82 const successMessage = document.getElementById('success-message');
83 if (successMessage) {
84 successMessage.focus();
85 }
86
87 // Reset form
88 setFormData({
89 name: '',
90 email: '',
91 phone: '',
92 message: '',
93 contactPreference: 'email',
94 newsletter: false
95 });
96 } catch (error) {
97 setErrors({
98 form: 'An error occurred while sending the form. Please try again later.'
99 });
100 // Move focus to the error message
101 const errorMessage = document.getElementById('form-error');
102 if (errorMessage) {
103 errorMessage.focus();
104 }
105 } finally {
106 setIsSubmitting(false);
107 }
108 };
109
110 return (
111 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
112 <h1 className="text-2xl font-bold mb-6">Contact us</h1>
113
114 {isSuccess && (
115 <div
116 id="success-message"
117 className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded"
118 role="alert"
119 tabIndex={-1} // Allows the element to receive focus
120 >
121 Thank you for submitting the form! We'll respond as soon as possible.
122 </div>
123 )}
124
125 {errors.form && (
126 <div
127 id="form-error"
128 className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded"
129 role="alert"
130 tabIndex={-1} // Allows the element to receive focus
131 >
132 {errors.form}
133 </div>
134 )}
135
136 <form onSubmit={handleSubmit} noValidate className="space-y-4">
137 <div>
138 <label htmlFor="name" className="block text-sm font-medium text-gray-700">
139 Full name <span className="text-red-500" aria-hidden="true">*</span>
140 </label>
141 <input
142 type="text"
143 id="name"
144 name="name"
145 value={formData.name}
146 onChange={handleChange}
147 aria-required="true"
148 aria-invalid={Boolean(errors.name)}
149 aria-describedby={errors.name ? 'name-error' : undefined}
150 className={`mt-1 block w-full rounded-md shadow-sm ${errors.name ? 'border-red-500' : 'border-gray-300'}`}
151 />
152 {errors.name && (
153 <p id="name-error" className="mt-1 text-sm text-red-600" role="alert">
154 {errors.name}
155 </p>
156 )}
157 </div>
158
159 <div>
160 <label htmlFor="email" className="block text-sm font-medium text-gray-700">
161 Email <span className="text-red-500" aria-hidden="true">*</span>
162 </label>
163 <input
164 type="email"
165 id="email"
166 name="email"
167 value={formData.email}
168 onChange={handleChange}
169 aria-required="true"
170 aria-invalid={Boolean(errors.email)}
171 aria-describedby={errors.email ? 'email-error' : undefined}
172 className={`mt-1 block w-full rounded-md shadow-sm ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
173 />
174 {errors.email && (
175 <p id="email-error" className="mt-1 text-sm text-red-600" role="alert">
176 {errors.email}
177 </p>
178 )}
179 </div>
180
181 <fieldset>
182 <legend className="text-sm font-medium text-gray-700">Preferred contact method</legend>
183 <div className="mt-2 space-y-2">
184 <div className="flex items-center">
185 <input
186 type="radio"
187 id="email-contact"
188 name="contactPreference"
189 value="email"
190 checked={formData.contactPreference === 'email'}
191 onChange={handleChange}
192 className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
193 />
194 <label htmlFor="email-contact" className="ml-2 block text-sm text-gray-700">
195 Email
196 </label>
197 </div>
198 <div className="flex items-center">
199 <input
200 type="radio"
201 id="phone-contact"
202 name="contactPreference"
203 value="phone"
204 checked={formData.contactPreference === 'phone'}
205 onChange={handleChange}
206 className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
207 />
208 <label htmlFor="phone-contact" className="ml-2 block text-sm text-gray-700">
209 Phone
210 </label>
211 </div>
212 </div>
213 </fieldset>
214
215 <div className={formData.contactPreference === 'phone' ? 'block' : 'hidden'}>
216 <label htmlFor="phone" className="block text-sm font-medium text-gray-700">
217 Phone number {formData.contactPreference === 'phone' && <span className="text-red-500" aria-hidden="true">*</span>}
218 </label>
219 <input
220 type="tel"
221 id="phone"
222 name="phone"
223 value={formData.phone}
224 onChange={handleChange}
225 aria-required={formData.contactPreference === 'phone'}
226 aria-invalid={Boolean(errors.phone)}
227 aria-describedby={errors.phone ? 'phone-error' : 'phone-hint'}
228 className={`mt-1 block w-full rounded-md shadow-sm ${errors.phone ? 'border-red-500' : 'border-gray-300'}`}
229 />
230 <p id="phone-hint" className="mt-1 text-xs text-gray-500">
231 Format: 123-456-789
232 </p>
233 {errors.phone && (
234 <p id="phone-error" className="mt-1 text-sm text-red-600" role="alert">
235 {errors.phone}
236 </p>
237 )}
238 </div>
239
240 <div>
241 <label htmlFor="message" className="block text-sm font-medium text-gray-700">
242 Message <span className="text-red-500" aria-hidden="true">*</span>
243 </label>
244 <textarea
245 id="message"
246 name="message"
247 rows={4}
248 value={formData.message}
249 onChange={handleChange}
250 aria-required="true"
251 aria-invalid={Boolean(errors.message)}
252 aria-describedby={errors.message ? 'message-error' : undefined}
253 className={`mt-1 block w-full rounded-md shadow-sm ${errors.message ? 'border-red-500' : 'border-gray-300'}`}
254 />
255 {errors.message && (
256 <p id="message-error" className="mt-1 text-sm text-red-600" role="alert">
257 {errors.message}
258 </p>
259 )}
260 </div>
261
262 <div className="flex items-center">
263 <input
264 type="checkbox"
265 id="newsletter"
266 name="newsletter"
267 checked={formData.newsletter}
268 onChange={handleChange}
269 className="h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
270 />
271 <label htmlFor="newsletter" className="ml-2 block text-sm text-gray-700">
272 Sign me up for the newsletter
273 </label>
274 </div>
275
276 <div className="pt-2">
277 <p className="text-xs text-gray-500">
278 Fields marked with <span className="text-red-500" aria-hidden="true">*</span> are required
279 </p>
280 </div>
281
282 <button
283 type="submit"
284 disabled={isSubmitting}
285 aria-disabled={isSubmitting}
286 className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
287 >
288 {isSubmitting ? 'Sending...' : 'Send message'}
289 </button>
290 </form>
291 </div>
292 );
293}Key accessibility elements in the above example:
- Correctly associated labels with fields - each label has an
htmlForattribute linked to the field'sid - Grouping with fieldset and legend - for radio buttons
- ARIA attributes -
aria-required,aria-invalid,aria-describedby - Dynamic visibility - the phone field appears only when needed
- Error messages with role="alert" - for screen readers
- Focus management - moving focus to the first error or the success message
Advanced practices for accessible forms
1. Focus management
Proper focus management is crucial for keyboard users and screen readers:
1import { useRef, useEffect } from 'react';
2
3function FocusManagementExample() {
4 const firstInputRef = useRef<HTMLInputElement>(null);
5 const errorMessageRef = useRef<HTMLDivElement>(null);
6 const successMessageRef = useRef<HTMLDivElement>(null);
7
8 // Autofocus on the first form field on mount
9 useEffect(() => {
10 if (firstInputRef.current) {
11 firstInputRef.current.focus();
12 }
13 }, []);
14
15 // Example function to move focus to the first error
16 const moveToFirstError = () => {
17 if (errorMessageRef.current) {
18 errorMessageRef.current.focus();
19 errorMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
20 }
21 };
22
23 // Example function to move focus to the success message
24 const moveToSuccessMessage = () => {
25 if (successMessageRef.current) {
26 successMessageRef.current.focus();
27 successMessageRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
28 }
29 };
30
31 return (
32 <form>
33 {/* Error message with ability to receive focus */}
34 <div
35 ref={errorMessageRef}
36 tabIndex={-1}
37 role="alert"
38 className="bg-red-100 p-3 rounded"
39 style={{ display: 'none' }} // Normally controlled by state
40 >
41 Errors occurred in the form
42 </div>
43
44 {/* First form field */}
45 <div>
46 <label htmlFor="first-field">First name</label>
47 <input
48 ref={firstInputRef}
49 id="first-field"
50 type="text"
51 />
52 </div>
53
54 {/* Success message */}
55 <div
56 ref={successMessageRef}
57 tabIndex={-1}
58 role="status"
59 className="bg-green-100 p-3 rounded"
60 style={{ display: 'none' }} // Normally controlled by state
61 >
62 The form was sent successfully!
63 </div>
64
65 {/* Buttons to demonstrate focus movement */}
66 <div className="mt-4 space-x-2">
67 <button type="button" onClick={moveToFirstError}>
68 Show error
69 </button>
70 <button type="button" onClick={moveToSuccessMessage}>
71 Show success
72 </button>
73 </div>
74 </form>
75 );
76}2. Autocomplete fields
Well-implemented autocomplete fields can significantly improve form usability:
1import { useState, useRef, KeyboardEvent } from 'react';
2
3interface Props {
4 suggestions: string[];
5 label: string;
6 id: string;
7 value: string;
8 onChange: (value: string) => void;
9}
10
11function AccessibleAutocomplete({ suggestions, label, id, value, onChange }: Props) {
12 const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
13 const [showSuggestions, setShowSuggestions] = useState(false);
14 const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
15 const suggestionsListRef = useRef<HTMLUListElement>(null);
16
17 // Filter suggestions based on current input
18 const filterSuggestions = (input: string) => {
19 const filtered = suggestions.filter(suggestion =>
20 suggestion.toLowerCase().includes(input.toLowerCase())
21 );
22 setFilteredSuggestions(filtered);
23 setShowSuggestions(true);
24 setActiveSuggestionIndex(0);
25 };
26
27 // Handle value change
28 const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
29 const userInput = e.target.value;
30 onChange(userInput);
31 filterSuggestions(userInput);
32 };
33
34 // Handle suggestion click
35 const handleSuggestionClick = (suggestion: string) => {
36 onChange(suggestion);
37 setShowSuggestions(false);
38 setActiveSuggestionIndex(0);
39 };
40
41 // Handle keyboard navigation
42 const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
43 // Arrow down - move to next suggestion
44 if (e.key === 'ArrowDown') {
45 e.preventDefault();
46 setActiveSuggestionIndex(prev =>
47 prev < filteredSuggestions.length - 1 ? prev + 1 : prev
48 );
49 }
50 // Arrow up - move to previous suggestion
51 else if (e.key === 'ArrowUp') {
52 e.preventDefault();
53 setActiveSuggestionIndex(prev => (prev > 0 ? prev - 1 : 0));
54 }
55 // Enter - select active suggestion
56 else if (e.key === 'Enter' && showSuggestions) {
57 e.preventDefault();
58 if (filteredSuggestions[activeSuggestionIndex]) {
59 handleSuggestionClick(filteredSuggestions[activeSuggestionIndex]);
60 }
61 }
62 // Escape - close suggestions list
63 else if (e.key === 'Escape') {
64 setShowSuggestions(false);
65 }
66 };
67
68 // ID for the suggestions list (to link with ARIA)
69 const listId = `${id}-suggestions`;
70
71 return (
72 <div className="relative">
73 <label htmlFor={id} className="block text-sm font-medium text-gray-700">
74 {label}
75 </label>
76 <input
77 type="text"
78 id={id}
79 value={value}
80 onChange={handleInputChange}
81 onKeyDown={handleKeyDown}
82 onFocus={() => filterSuggestions(value)}
83 onBlur={() => {
84 // Delay to allow suggestion click
85 setTimeout(() => setShowSuggestions(false), 200);
86 }}
87 aria-autocomplete="list"
88 aria-controls={listId}
89 aria-activedescendant={
90 showSuggestions && filteredSuggestions.length > 0
91 ? `${id}-suggestion-${activeSuggestionIndex}`
92 : undefined
93 }
94 className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
95 />
96
97 {/* Suggestions list */}
98 {showSuggestions && filteredSuggestions.length > 0 && (
99 <ul
100 id={listId}
101 ref={suggestionsListRef}
102 role="listbox"
103 className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5"
104 >
105 {filteredSuggestions.map((suggestion, index) => (
106 <li
107 key={index}
108 id={`${id}-suggestion-${index}`}
109 role="option"
110 aria-selected={index === activeSuggestionIndex}
111 onClick={() => handleSuggestionClick(suggestion)}
112 className={`cursor-default select-none relative py-2 px-4 ${index === activeSuggestionIndex ? 'bg-blue-100' : ''}`}
113 >
114 {suggestion}
115 </li>
116 ))}
117 </ul>
118 )}
119 </div>
120 );
121}Key accessibility elements in autocomplete:
- ARIA roles -
aria-autocomplete="list",role="listbox",role="option" - aria-activedescendant - to mark the active suggestion
- aria-controls - links the field with the suggestions list
- Keyboard support - arrows, Enter, Escape
3. Date pickers and masked inputs
Date fields and other complex inputs should be accessible and easy to use:
1import { useState, useRef, ChangeEvent } from 'react';
2
3function AccessibleDateInput() {
4 const [date, setDate] = useState('');
5 const inputRef = useRef<HTMLInputElement>(null);
6
7 // Format date in DD-MM-YYYY format
8 const formatDate = (input: string) => {
9 // Remove all non-digit characters
10 const digitsOnly = input.replace(/\D/g, '');
11
12 // Add dashes in the appropriate places
13 let formatted = '';
14 for (let i = 0; i < digitsOnly.length && i < 8; i++) {
15 if (i === 2 || i === 4) formatted += '-';
16 formatted += digitsOnly[i];
17 }
18
19 return formatted;
20 };
21
22 const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
23 const input = e.target.value;
24 const formatted = formatDate(input);
25 setDate(formatted);
26
27 // Zachowanie pozycji kursora po formatowaniu
28 if (inputRef.current) {
29 const cursorPosition = e.target.selectionStart || 0;
30 const diffLength = formatted.length - input.length;
31 setTimeout(() => {
32 inputRef.current?.setSelectionRange(cursorPosition + diffLength, cursorPosition + diffLength);
33 }, 0);
34 }
35 };
36
37 // Date validation
38 const validateDate = (date: string) => {
39 // Check DD-MM-YYYY format
40 if (!/^\d{2}-\d{2}-\d{4}$/.test(date)) return false;
41
42 const [day, month, year] = date.split('-').map(Number);
43
44 // Check month range
45 if (month < 1 || month > 12) return false;
46
47 // Check number of days in the month
48 const daysInMonth = new Date(year, month, 0).getDate();
49 if (day < 1 || day > daysInMonth) return false;
50
51 return true;
52 };
53
54 // Generate instructions for screen readers
55 const dateInstructions = 'Enter the date in DD-MM-YYYY format, for example 01-06-2023 for June 1, 2023.';
56
57 return (
58 <div>
59 <label htmlFor="date-input" className="block text-sm font-medium text-gray-700">
60 Date
61 </label>
62 <input
63 ref={inputRef}
64 type="text"
65 id="date-input"
66 value={date}
67 onChange={handleChange}
68 aria-describedby="date-instructions"
69 aria-invalid={date !== '' && !validateDate(date)}
70 className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
71 />
72 <p id="date-instructions" className="mt-1 text-xs text-gray-500">
73 {dateInstructions}
74 </p>
75 {date !== '' && !validateDate(date) && (
76 <p className="mt-1 text-sm text-red-600" role="alert">
77 The entered date is invalid. {dateInstructions}
78 </p>
79 )}
80 </div>
81 );
82}4. Reusable accessible form components
Creating a library of reusable, accessible form components can significantly simplify building consistent and accessible forms across the entire application:
1// components/FormElements.tsx
2import { InputHTMLAttributes, TextareaHTMLAttributes, ReactNode } from 'react';
3
4// Label component
5interface LabelProps {
6 htmlFor: string;
7 children: ReactNode;
8 required?: boolean;
9}
10
11export function FormLabel({ htmlFor, children, required }: LabelProps) {
12 return (
13 <label htmlFor={htmlFor} className="block text-sm font-medium text-gray-700">
14 {children}
15 {required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
16 </label>
17 );
18}
19
20// Text input component
21interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
22 id: string;
23 label: string;
24 error?: string;
25 hint?: string;
26 required?: boolean;
27}
28
29export function TextInput({
30 id,
31 label,
32 error,
33 hint,
34 required,
35 ...props
36}: TextInputProps) {
37 const errorId = error ? `${id}-error` : undefined;
38 const hintId = hint ? `${id}-hint` : undefined;
39 const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
40
41 return (
42 <div className="mb-4">
43 <FormLabel htmlFor={id} required={required}>{label}</FormLabel>
44 <input
45 id={id}
46 type="text"
47 aria-invalid={Boolean(error)}
48 aria-required={required}
49 aria-describedby={describedBy}
50 className={`mt-1 block w-full rounded-md ${error ? 'border-red-500' : 'border-gray-300'}`}
51 {...props}
52 />
53 {hint && !error && (
54 <p id={hintId} className="mt-1 text-xs text-gray-500">
55 {hint}
56 </p>
57 )}
58 {error && (
59 <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
60 {error}
61 </p>
62 )}
63 </div>
64 );
65}
66
67// Multi-line text input component
68interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
69 id: string;
70 label: string;
71 error?: string;
72 hint?: string;
73 required?: boolean;
74}
75
76export function Textarea({
77 id,
78 label,
79 error,
80 hint,
81 required,
82 ...props
83}: TextareaProps) {
84 const errorId = error ? `${id}-error` : undefined;
85 const hintId = hint ? `${id}-hint` : undefined;
86 const describedBy = [errorId, hintId].filter(Boolean).join(' ') || undefined;
87
88 return (
89 <div className="mb-4">
90 <FormLabel htmlFor={id} required={required}>{label}</FormLabel>
91 <textarea
92 id={id}
93 aria-invalid={Boolean(error)}
94 aria-required={required}
95 aria-describedby={describedBy}
96 className={`mt-1 block w-full rounded-md ${error ? 'border-red-500' : 'border-gray-300'}`}
97 {...props}
98 />
99 {hint && !error && (
100 <p id={hintId} className="mt-1 text-xs text-gray-500">
101 {hint}
102 </p>
103 )}
104 {error && (
105 <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
106 {error}
107 </p>
108 )}
109 </div>
110 );
111}
112
113// Radio or checkbox group component
114interface OptionItem {
115 id: string;
116 value: string;
117 label: string;
118}
119
120interface CheckboxOrRadioGroupProps {
121 legend: string;
122 name: string;
123 options: OptionItem[];
124 type: 'checkbox' | 'radio';
125 values: string[];
126 onChange: (value: string, checked: boolean) => void;
127 error?: string;
128 required?: boolean;
129}
130
131export function OptionGroup({
132 legend,
133 name,
134 options,
135 type,
136 values,
137 onChange,
138 error,
139 required
140}: CheckboxOrRadioGroupProps) {
141 const errorId = error ? `${name}-error` : undefined;
142
143 return (
144 <fieldset className="mb-4">
145 <legend className="text-sm font-medium text-gray-700">
146 {legend}
147 {required && <span className="text-red-500 ml-1" aria-hidden="true">*</span>}
148 </legend>
149
150 <div className="mt-2 space-y-2" role={type === 'radio' ? 'radiogroup' : undefined} aria-required={required}>
151 {options.map(option => (
152 <div key={option.id} className="flex items-center">
153 <input
154 type={type}
155 id={option.id}
156 name={name}
157 value={option.value}
158 checked={type === 'radio' ? values[0] === option.value : values.includes(option.value)}
159 onChange={(e) => onChange(option.value, e.target.checked)}
160 aria-describedby={errorId}
161 className="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
162 />
163 <label htmlFor={option.id} className="ml-2 block text-sm text-gray-700">
164 {option.label}
165 </label>
166 </div>
167 ))}
168 </div>
169
170 {error && (
171 <p id={errorId} className="mt-1 text-sm text-red-600" role="alert">
172 {error}
173 </p>
174 )}
175 </fieldset>
176 );
177}Example usage of reusable components:
1import { useState } from 'react';
2import { TextInput, Textarea, OptionGroup } from '../components/FormElements';
3
4export default function SurveyForm() {
5 const [formData, setFormData] = useState({
6 name: '',
7 email: '',
8 feedback: '',
9 satisfaction: '',
10 interests: [] as string[]
11 });
12
13 const [errors, setErrors] = useState<Record<string, string>>({});
14
15 const handleInputChange = (field: string, value: string) => {
16 setFormData(prev => ({ ...prev, [field]: value }));
17
18 // Remove error after editing
19 if (errors[field]) {
20 setErrors(prev => {
21 const newErrors = { ...prev };
22 delete newErrors[field];
23 return newErrors;
24 });
25 }
26 };
27
28 const handleCheckboxChange = (value: string, checked: boolean) => {
29 setFormData(prev => {
30 if (checked) {
31 return { ...prev, interests: [...prev.interests, value] };
32 } else {
33 return { ...prev, interests: prev.interests.filter(item => item !== value) };
34 }
35 });
36
37 // Remove error after editing
38 if (errors.interests) {
39 setErrors(prev => {
40 const newErrors = { ...prev };
41 delete newErrors.interests;
42 return newErrors;
43 });
44 }
45 };
46
47 const handleRadioChange = (value: string, checked: boolean) => {
48 if (checked) {
49 setFormData(prev => ({ ...prev, satisfaction: value }));
50
51 // Remove error after editing
52 if (errors.satisfaction) {
53 setErrors(prev => {
54 const newErrors = { ...prev };
55 delete newErrors.satisfaction;
56 return newErrors;
57 });
58 }
59 }
60 };
61
62 const validateForm = () => {
63 const newErrors: Record<string, string> = {};
64
65 if (!formData.name.trim()) {
66 newErrors.name = 'Name is required';
67 }
68
69 if (!formData.email.trim()) {
70 newErrors.email = 'Email is required';
71 } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
72 newErrors.email = 'Please provide a valid email address';
73 }
74
75 if (!formData.feedback.trim()) {
76 newErrors.feedback = 'Feedback is required';
77 }
78
79 if (!formData.satisfaction) {
80 newErrors.satisfaction = 'Please select a satisfaction level';
81 }
82
83 if (formData.interests.length === 0) {
84 newErrors.interests = 'Select at least one category';
85 }
86
87 setErrors(newErrors);
88 return Object.keys(newErrors).length === 0;
89 };
90
91 const handleSubmit = (e: React.FormEvent) => {
92 e.preventDefault();
93
94 if (validateForm()) {
95 // Submit the form
96 console.log('Form data:', formData);
97 alert('Form sent successfully!');
98 }
99 };
100
101 return (
102 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
103 <h1 className="text-2xl font-bold mb-6">User survey</h1>
104
105 <form onSubmit={handleSubmit} noValidate>
106 <TextInput
107 id="name"
108 label="Full name"
109 required
110 value={formData.name}
111 onChange={(e) => handleInputChange('name', e.target.value)}
112 error={errors.name}
113 />
114
115 <TextInput
116 id="email"
117 label="Email"
118 type="email"
119 required
120 value={formData.email}
121 onChange={(e) => handleInputChange('email', e.target.value)}
122 error={errors.email}
123 hint="We'll never share your email address"
124 />
125
126 <Textarea
127 id="feedback"
128 label="Your feedback"
129 required
130 value={formData.feedback}
131 onChange={(e) => handleInputChange('feedback', e.target.value)}
132 error={errors.feedback}
133 rows={4}
134 />
135
136 <OptionGroup
137 legend="Satisfaction level"
138 name="satisfaction"
139 type="radio"
140 required
141 values={formData.satisfaction ? [formData.satisfaction] : []}
142 onChange={handleRadioChange}
143 error={errors.satisfaction}
144 options={[
145 { id: 'satisfaction-very-satisfied', value: 'very-satisfied', label: 'Very satisfied' },
146 { id: 'satisfaction-satisfied', value: 'satisfied', label: 'Satisfied' },
147 { id: 'satisfaction-neutral', value: 'neutral', label: 'Neutral' },
148 { id: 'satisfaction-dissatisfied', value: 'dissatisfied', label: 'Dissatisfied' },
149 { id: 'satisfaction-very-dissatisfied', value: 'very-dissatisfied', label: 'Very dissatisfied' }
150 ]}
151 />
152
153 <OptionGroup
154 legend="Interests"
155 name="interests"
156 type="checkbox"
157 required
158 values={formData.interests}
159 onChange={handleCheckboxChange}
160 error={errors.interests}
161 options={[
162 { id: 'interests-technology', value: 'technology', label: 'Technology' },
163 { id: 'interests-design', value: 'design', label: 'Design' },
164 { id: 'interests-business', value: 'business', label: 'Business' },
165 { id: 'interests-health', value: 'health', label: 'Health' },
166 { id: 'interests-education', value: 'education', label: 'Education' }
167 ]}
168 />
169
170 <div className="mt-6">
171 <button
172 type="submit"
173 className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
174 >
175 Send survey
176 </button>
177 </div>
178 </form>
179 </div>
180 );
181}Testing form accessibility
Merely implementing accessible forms is only half the battle. Testing them is also necessary:
1. Automated accessibility testing
You can use tools like @axe-core/react to test accessibility during development:
1// In the test file (e.g., FormComponent.test.tsx)
2import React from 'react';
3import { render } from '@testing-library/react';
4import { axe, toHaveNoViolations } from 'jest-axe';
5import ContactForm from '../ContactForm';
6
7// Add matcher to Jest
8expect.extend(toHaveNoViolations);
9
10describe('ContactForm', () => {
11 it('should not have accessibility violations', async () => {
12 const { container } = render(<ContactForm />);
13 const results = await axe(container);
14 expect(results).toHaveNoViolations();
15 });
16});2. Manual accessibility testing
Automated tests don't catch all problems. Manual testing is also required:
Keyboard navigation testing:
- Can you navigate the form using only the keyboard (Tab, Shift+Tab, Enter, space)?
- Is focus clearly visible on every field?
- Can you activate buttons and links with the keyboard?
Testing with screen readers:
- NVDA or JAWS (Windows)
- VoiceOver (macOS)
- TalkBack (Android)
- VoiceOver (iOS)
Zoom testing:
- Is the form usable when text is zoomed to 200%?
- Does the form layout adapt to zooming?
3. Form accessibility checklist
Before deploying a form, check it against the following criteria:
Structural:
- Do all form fields have associated labels?
- Are radio and checkbox fields grouped with
<fieldset>and<legend>? - Are ARIA attributes used correctly?
Interactivity:
- Can you navigate the form via keyboard?
- Can you activate all controls with the keyboard?
- Is keyboard focus visible?
Feedback:
- Are validation errors clearly communicated?
- Are error messages linked to fields via
aria-describedby? - Is the form submission status clearly communicated?
Visual:
- Is the text/background contrast sufficient (minimum 4.5:1)?
- Is the form usable at various screen sizes?
- Are visual indicators (e.g., colors) complemented by other cues?
Summary
Creating usable and accessible forms is a key aspect of modern web applications. Properly designed forms contribute to increased conversion, improved user experience, and provide access to your services to a wider audience.
Main principles to remember:
- Clear structure - logical field organization, clear labels, grouping of related fields
- Keyboard accessibility - full keyboard support, visible focus, logical tab order
- Correct ARIA - using ARIA attributes to improve screen reader users' experience
- Effective communication - clear labels, helper texts, precise error messages
- Validation - immediate feedback, clear instructions, validation both on client and server
- Testing - both automated and manual, with various assistive devices
By building forms following these principles, you can create an accessible and user-friendly experience that will serve all visitors to your application.
Code for this lesson: App.tsx
1import React, { useState, useRef, useEffect } from 'react';
2
3// Form accessibility - ARIA, labels, focus management
4
5interface A11yIssue {
6 id: string;
7 label: string;
8 bad: string;
9 good: string;
10 description: string;
11}
12
13const a11yIssues: A11yIssue[] = [
14 {
15 id: 'labels',
16 label: 'Label + htmlFor',
17 bad: '<input placeholder="Email" />',
18 good: '<label htmlFor="email">Email</label>\n<input id="email" />',
19 description: 'Every field must have an associated label. Screen readers read the label.',
20 },
21 {
22 id: 'aria',
23 label: 'aria-describedby',
24 bad: '<input />\n<span>Min. 8 characters</span>',
25 good: '<input aria-describedby="hint" />\n<span id="hint">Min. 8 characters</span>',
26 description: 'aria-describedby links the field to an error description or hint.',
27 },
28 {
29 id: 'error',
30 label: 'aria-invalid + role="alert"',
31 bad: '<input style={{border:"red"}} />\n<span>Error!</span>',
32 good: '<input aria-invalid="true" />\n<span role="alert">Error!</span>',
33 description: 'aria-invalid and role="alert" inform screen readers about errors.',
34 },
35 {
36 id: 'focus',
37 label: 'Focus management',
38 bad: '// No focus management\n// The user does not know where the error is',
39 good: '// After an error - focus the first invalid input\nfirstErrorRef.current?.focus()',
40 description: 'After a validation error, move focus to the first invalid element.',
41 },
42];
43
44export default function AccessibilityDemo() {
45 const [selected, setSelected] = useState<A11yIssue>(a11yIssues[0]);
46 const [showDemo, setShowDemo] = useState(false);
47 const [name, setName] = useState('');
48 const [email, setEmail] = useState('');
49 const [errors, setErrors] = useState<Record<string, string>>({});
50 const nameRef = useRef<HTMLInputElement>(null);
51
52 const handleSubmit = (e: React.FormEvent) => {
53 e.preventDefault();
54 const errs: Record<string, string> = {};
55 if (!name) errs.name = 'Name is required';
56 if (!email) errs.email = 'Email is required';
57 else if (!/\S+@\S+\.\S+/.test(email)) errs.email = 'Invalid email';
58 setErrors(errs);
59
60 // Focus management - move to the first invalid input
61 if (Object.keys(errs).length > 0 && errs.name) {
62 nameRef.current?.focus();
63 }
64 };
65
66 const inputStyle: React.CSSProperties = {
67 width: '100%', padding: '10px', background: '#0d1117',
68 border: '1px solid #333', borderRadius: 6, color: '#e0e0e0',
69 fontFamily: 'monospace', fontSize: 13, boxSizing: 'border-box'
70 };
71
72 return (
73 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
74 <h1 style={{ color: '#64ffda' }}>Form Accessibility (a11y)</h1>
75
76 <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
77 {a11yIssues.map(issue => (
78 <button key={issue.id} onClick={() => setSelected(issue)} style={{
79 background: selected.id === issue.id ? '#1565c0' : '#1a2744',
80 color: '#fff', border: `1px solid ${selected.id === issue.id ? '#64ffda' : '#333'}`,
81 borderRadius: 6, padding: '6px 12px', cursor: 'pointer', fontSize: 11, fontFamily: 'monospace'
82 }}>
83 {issue.label}
84 </button>
85 ))}
86 </div>
87
88 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
89 <div style={{ background: '#3e1010', borderRadius: 8, padding: 12, border: '1px solid #f44336' }}>
90 <div style={{ color: '#f44336', fontSize: 12, marginBottom: 8 }}>Bad (inaccessible):</div>
91 <pre style={{ color: '#ffcdd2', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{selected.bad}</pre>
92 </div>
93 <div style={{ background: '#0d2818', borderRadius: 8, padding: 12, border: '1px solid #4caf50' }}>
94 <div style={{ color: '#4caf50', fontSize: 12, marginBottom: 8 }}>Good (accessible):</div>
95 <pre style={{ color: '#c8e6c9', fontSize: 11, margin: 0, whiteSpace: 'pre-wrap' }}>{selected.good}</pre>
96 </div>
97 </div>
98 <p style={{ color: '#aaa', fontSize: 13, marginBottom: 16 }}>{selected.description}</p>
99
100 <button onClick={() => setShowDemo(!showDemo)} style={{
101 background: '#9c27b0', color: '#fff', border: 'none', borderRadius: 6,
102 padding: '8px 16px', cursor: 'pointer', fontFamily: 'monospace', marginBottom: 16
103 }}>
104 {showDemo ? 'Hide' : 'Show'} accessible demo form
105 </button>
106
107 {showDemo && (
108 <form onSubmit={handleSubmit} style={{ maxWidth: 400 }} aria-label="Registration form">
109 <div style={{ marginBottom: 12 }}>
110 <label htmlFor="a11y-name" style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
111 Name *
112 </label>
113 <input
114 ref={nameRef} id="a11y-name" value={name}
115 onChange={e => setName(e.target.value)}
116 aria-invalid={!!errors.name} aria-describedby={errors.name ? 'name-error' : undefined}
117 style={{ ...inputStyle, borderColor: errors.name ? '#f44336' : '#333' }}
118 />
119 {errors.name && <div id="name-error" role="alert" style={{ color: '#f44336', fontSize: 11, marginTop: 4 }}>{errors.name}</div>}
120 </div>
121
122 <div style={{ marginBottom: 12 }}>
123 <label htmlFor="a11y-email" style={{ color: '#64ffda', fontSize: 12, display: 'block', marginBottom: 4 }}>
124 Email *
125 </label>
126 <input
127 id="a11y-email" type="email" value={email}
128 onChange={e => setEmail(e.target.value)}
129 aria-invalid={!!errors.email} aria-describedby="email-hint"
130 style={{ ...inputStyle, borderColor: errors.email ? '#f44336' : '#333' }}
131 />
132 <div id="email-hint" style={{ color: '#888', fontSize: 11, marginTop: 4 }}>
133 {errors.email ? <span role="alert" style={{ color: '#f44336' }}>{errors.email}</span> : 'Format: name@domain.com'}
134 </div>
135 </div>
136
137 <button type="submit" style={{
138 background: '#4caf50', color: '#fff', border: 'none', borderRadius: 6,
139 padding: '10px 20px', cursor: 'pointer', fontFamily: 'monospace', width: '100%'
140 }}>
141 Submit (with focus management)
142 </button>
143 </form>
144 )}
145 </div>
146 );
147}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What is the recommended error handling pattern in Server Actions in Next.js?
2. The useActionState hook (formerly useFormState) in Server Actions is used for:
These are 2 of 4 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Create a reusable custom hook useForm with validation, submission handling, and error management
- Code editor
Create a photo upload form with preview, drag&drop, progress bar, and type/size validation
- Code editor
Create a form where fields appear conditionally based on previous selections (e.g., payment method -> different fields)
- Vertical ordering
Arrange the elements in the correct order: Setup provider β Create login β Protect routes β Handle sessions
- Click in order
Click elements in order to build the syntax <form action={serverAction}>
- Click in order
Click elements in order to build the Zod validation chain: z.string().email().min(5)