Design forms that everyone can complete — persistent visible labels, error descriptions in text, inline hints, and validation strategies that work for screen reader, keyboard, and cognitive accessibility.
Design forms that everyone can complete — persistent visible labels, error descriptions in text, inline hints, and validation strategies that work for screen reader, keyboard, and cognitive accessibility.
Placeholder text disappears when users start typing — it cannot serve as a label. Using placeholder as the only label violates WCAG 1.3.1 (Info and Relationships) and creates severe usability issues for people with memory impairments, users filling long forms, and anyone who needs to review their entries before submission. Every form field requires a persistent visible label.
1<!-- WRONG: Placeholder-only — label disappears on input -->
2<input type="email" placeholder="Email address" />
3
4<!-- CORRECT: Persistent label above the field -->
5<div class="form-field">
6 <label for="email">Email address</label>
7 <input
8 id="email"
9 type="email"
10 autocomplete="email"
11 aria-required="true"
12 />
13</div>
14
15<!-- ACCEPTABLE: Floating label (label visible before and after input) -->
16<!-- But requires careful implementation to stay visible at all times -->
17<div class="floating-label-field">
18 <input
19 id="email-floating"
20 type="email"
21 placeholder=" " /* Non-empty placeholder triggers CSS float */
22 />
23 <label for="email-floating">Email address</label>
24</div>1/* Floating label pattern — label always visible */
2.floating-label-field {
3 position: relative;
4}
5
6.floating-label-field input:not(:placeholder-shown) + label,
7.floating-label-field input:focus + label {
8 transform: translateY(-150%) scale(0.85);
9 color: var(--color-interactive-primary);
10}WCAG 3.3.1 (Error Identification, Level A) requires errors to be identified in text. WCAG 3.3.3 (Error Suggestion, Level AA) requires the error message to suggest how to correct the error. Both together: errors must describe what went wrong AND how to fix it.
1// Accessible error message pattern
2interface FormFieldProps {
3 id: string;
4 label: string;
5 error?: string;
6 hint?: string;
7 required?: boolean;
8}
9
10function FormField({ id, label, error, hint, required }: FormFieldProps) {
11 const hintId = `${id}-hint`;
12 const errorId = `${id}-error`;
13 const describedBy = [hint && hintId, error && errorId]
14 .filter(Boolean).join(' ');
15
16 return (
17 <div className="form-field">
18 <label htmlFor={id}>
19 {label}
20 {required && <span aria-hidden="true"> *</span>}
21 {required && <span className="sr-only">(required)</span>}
22 </label>
23
24 {hint && (
25 <p id={hintId} className="field-hint">{hint}</p>
26 )}
27
28 <input
29 id={id}
30 aria-required={required}
31 aria-invalid={error ? 'true' : undefined}
32 aria-describedby={describedBy || undefined}
33 />
34
35 {error && (
36 <p id={errorId} className="field-error" role="alert">
37 {/* Icon is decorative — text carries the meaning */}
38 <span aria-hidden="true">⚠</span>
39 {error}
40 </p>
41 )}
42 </div>
43 );
44}
45
46// Good error messages:
47// BAD: "Invalid email"
48// GOOD: "Enter a valid email address. For example: [email protected]"
49// BAD: "Password error"
50// GOOD: "Password must be at least 8 characters and include one number"Format requirements (password length, phone number format, date format) should be visible before the user attempts to submit, not revealed as errors after. Inline hints provide this upfront guidance:
1<div class="form-field">
2 <label for="phone">
3 Phone number
4 <span aria-hidden="true"> *</span>
5 <span class="sr-only">(required)</span>
6 </label>
7 <!-- Hint is always visible, before any validation -->
8 <p id="phone-hint" class="field-hint">
9 Include country code. Example: +1 (555) 000-0000
10 </p>
11 <input
12 id="phone"
13 type="tel"
14 autocomplete="tel"
15 aria-required="true"
16 aria-describedby="phone-hint"
17 placeholder="+1 (555) 000-0000"
18 />
19</div>
20
21<div class="form-field">
22 <label for="password">Password</label>
23 <!-- Character count hint — updates dynamically -->
24 <p id="password-requirements" class="field-hint">
25 8-32 characters, include at least one number and one uppercase letter
26 </p>
27 <input
28 id="password"
29 type="password"
30 aria-describedby="password-requirements"
31 aria-required="true"
32 />
33 <!-- Live character counter for remaining characters -->
34 <p aria-live="polite" aria-atomic="true" class="char-count">
35 <span id="chars-remaining">32</span> characters remaining
36 </p>
37</div>WCAG 1.4.1 (Use of Color, Level A) prohibits using color as the only visual means of conveying information. Validation state must use color plus at least one other indicator (icon, text, pattern, border width change):
1/* Invalid: color alone indicates state */
2.field-error input {
3 border-color: #DC2626; /* Only red — fails 1.4.1 */
4}
5
6/* Valid: color + border width + error text (three indicators) */
7.field-has-error input {
8 border-color: #DC2626;
9 border-width: 2px; /* Second indicator */
10 /* Plus: the error message text is the third indicator */
11}
12
13/* Valid: color + icon inside the field */
14.field-has-error .field-wrapper {
15 position: relative;
16}
17.field-has-error .field-wrapper::after {
18 content: '⚠';
19 position: absolute;
20 right: 0.75rem;
21 top: 50%;
22 transform: translateY(-50%);
23 color: #DC2626;
24 pointer-events: none;
25 /* Icon is supplementary — error text carries the primary meaning */
26}Success state also needs a non-color indicator. A green checkmark icon plus green border plus 'Valid email address' text message satisfies the requirement. A green border alone does not.
Groups of related form fields need explicit grouping so screen readers can understand the relationship:
1<!-- Fieldset for radio groups and checkbox groups -->
2<fieldset>
3 <legend>Preferred contact method</legend>
4 <label><input type="radio" name="contact" value="email"> Email</label>
5 <label><input type="radio" name="contact" value="phone"> Phone</label>
6 <label><input type="radio" name="contact" value="sms"> SMS</label>
7</fieldset>
8
9<!-- Multi-step form with clear step indicator -->
10<nav aria-label="Form progress">
11 <ol>
12 <li aria-current="step">Step 1: Personal info</li>
13 <li>Step 2: Account details</li>
14 <li>Step 3: Payment</li>
15 </ol>
16</nav>
17
18<!-- Each step's form has its own heading -->
19<form aria-labelledby="step-1-heading">
20 <h2 id="step-1-heading">Step 1: Personal information</h2>
21 <!-- Fields -->
22</form>For multi-step forms: announce step changes with an aria-live region, move focus to the step heading when the step changes, and never lose the user's previously entered data when navigating back. Use autosave or session storage to persist data across browser refreshes — losing form data after a validation error is one of the most frustrating accessibility experiences.
Audit your most complex form for: missing persistent labels, error messages that lack suggestion text, hints that only appear after validation, and color-only error indicators. Fix the highest-priority violation this sprint.
Your progress is saved in this browser, on this device only.