Form Accessibility (Labels, Inputs, Validation)
1. Input Labels (label, for attribute) and Descriptions
| Labeling Method | Syntax | Use Case | Screen Reader Behavior |
|---|---|---|---|
| <label> with for | <label for="id">...</label> |
Standard input labeling (recommended) | Announces label text when field focused |
| <label> wrapping | <label>Text <input></label> |
Implicit association (simpler markup) | Same as explicit label |
| aria-label | <input aria-label="Label text"> |
When visual label isn't needed/possible | Announces aria-label value |
| aria-labelledby | <input aria-labelledby="id1 id2"> |
Multiple labels or non-label elements | Concatenates referenced elements' text |
| aria-describedby | <input aria-describedby="help-id"> |
Additional help text or hints | Announces after label (often delayed) |
| title attribute AVOID | <input title="Label"> |
Legacy fallback only | Inconsistent screen reader support |
| placeholder NOT A LABEL | <input placeholder="Example"> |
Example text only, never use as label | Poor support, disappears on input |
Example: Comprehensive label patterns
<!-- Best: Explicit label with for attribute -->
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required>
<!-- Good: Wrapped label (implicit association) -->
<label>
Phone Number
<input type="tel" name="phone">
</label>
<!-- Good: Label with help text -->
<label for="username">Username</label>
<input
type="text"
id="username"
aria-describedby="username-help">
<span id="username-help" class="help-text">
Must be 3-20 characters, letters and numbers only
</span>
<!-- Multiple descriptors -->
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="pwd-requirements pwd-strength">
<div id="pwd-requirements">Min 8 characters, 1 uppercase, 1 number</div>
<div id="pwd-strength" aria-live="polite">Strength: Weak</div>
<!-- Labelledby for complex labels -->
<span id="price-label">Price</span>
<span id="currency-label">(USD)</span>
<input
type="number"
aria-labelledby="price-label currency-label">
<!-- Search with aria-label (icon button) -->
<input
type="search"
aria-label="Search products"
placeholder="Search...">
<button aria-label="Submit search">🔍</button>
Warning: Never use placeholder as the only label. It disappears
when users type, has poor contrast, and isn't reliably announced by screen readers. Always provide a proper
<label> element.
| Input Type | Required Attributes | Recommended Additions |
|---|---|---|
| Text/Email/Tel/URL | id, name, associated <label> |
autocomplete, inputmode, aria-describedby |
| Password | id, name, <label>,
autocomplete="current-password"
|
Show/hide toggle button, strength indicator |
| Checkbox/Radio | id, name, <label> |
Group with <fieldset> if related |
| Select | id, name, <label> |
Default option or placeholder option with disabled |
| Textarea | id, name, <label> |
maxlength, character counter (live region) |
| Range/Number | id, name, <label>, min, max |
step, aria-valuemin/max/now for custom sliders |
| File | id, name, <label> |
accept, instructions about file types/size |
2. Fieldsets and Form Grouping (fieldset, legend)
| Grouping Element | Purpose | Required Children | Use Case |
|---|---|---|---|
| <fieldset> | Groups related form controls | <legend> as first child |
Radio groups, checkbox groups, address sections |
| <legend> | Label for fieldset group | Must be first child of <fieldset> |
Announces group context to screen readers |
| role="group" | ARIA alternative to fieldset | aria-labelledby or aria-label |
When fieldset styling is problematic |
| role="radiogroup" | Semantic radio button group | aria-label or aria-labelledby |
Custom radio implementations |
Example: Fieldset patterns for different form sections
<!-- Radio button group -->
<fieldset>
<legend>Shipping Method</legend>
<label>
<input type="radio" name="shipping" value="standard" checked>
Standard (5-7 days) - Free
</label>
<label>
<input type="radio" name="shipping" value="express">
Express (2-3 days) - $9.99
</label>
<label>
<input type="radio" name="shipping" value="overnight">
Overnight - $24.99
</label>
</fieldset>
<!-- Checkbox group -->
<fieldset>
<legend>
Interests <span class="help-text">(Select all that apply)</span>
</legend>
<label>
<input type="checkbox" name="interests" value="web">
Web Development
</label>
<label>
<input type="checkbox" name="interests" value="mobile">
Mobile Development
</label>
<label>
<input type="checkbox" name="interests" value="design">
UI/UX Design
</label>
</fieldset>
<!-- Complex address group -->
<fieldset>
<legend>Billing Address</legend>
<label for="street">Street Address</label>
<input type="text" id="street" name="street" autocomplete="street-address">
<div class="form-row">
<div>
<label for="city">City</label>
<input type="text" id="city" name="city" autocomplete="address-level2">
</div>
<div>
<label for="state">State</label>
<select id="state" name="state" autocomplete="address-level1">
<option value="">Select state</option>
<option value="CA">California</option>
<option value="NY">New York</option>
</select>
</div>
<div>
<label for="zip">ZIP Code</label>
<input type="text" id="zip" name="zip" autocomplete="postal-code">
</div>
</div>
</fieldset>
<!-- ARIA group alternative (when fieldset styling is problematic) -->
<div role="group" aria-labelledby="payment-heading">
<h3 id="payment-heading">Payment Method</h3>
<label>
<input type="radio" name="payment" value="card">
Credit Card
</label>
<label>
<input type="radio" name="payment" value="paypal">
PayPal
</label>
</div>
| Fieldset Best Practice | Implementation | Reason |
|---|---|---|
| Always Use Legend | Include <legend> as first child |
Provides context for screen reader users |
| Keep Legend Concise | Short, descriptive text (3-8 words) | Read before each field in group |
| Don't Nest Fieldsets | Avoid nested fieldsets when possible | Complex nesting confuses screen readers |
| Style Appropriately | Remove default border if needed, keep semantic HTML | Visual design shouldn't compromise accessibility |
| Use for Related Groups | Group radio buttons, related checkboxes, address parts | Establishes relationship between controls |
3. Error Handling and Validation Messages (aria-invalid, aria-errormessage)
| Error Pattern | Implementation | ARIA Attributes | Timing |
|---|---|---|---|
| Inline Field Error | Error message below field, linked via aria-describedby |
aria-invalid="true", aria-describedby |
On blur or submit |
| Error Summary | List of errors at top of form | role="alert" or aria-live="assertive" |
On submit |
| Real-time Validation | Update error state as user types | aria-live="polite", aria-invalid |
During input (debounced) |
| Success Feedback | Confirmation message after correction | aria-live="polite", remove aria-invalid |
After valid input |
| Required Field Missing | Specific message about requirement | aria-required="true", aria-invalid="true" |
On submit or blur |
Example: Comprehensive error handling implementation
<!-- Error summary at form top -->
<form id="signup-form" novalidate>
<div id="error-summary" role="alert" aria-live="assertive" hidden>
<h2>Please correct the following errors:</h2>
<ul id="error-list"></ul>
</div>
<!-- Field with inline error -->
<div class="form-group">
<label for="email">Email Address <span aria-label="required">*</span></label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="false"
aria-describedby="email-error email-help">
<span id="email-help" class="help-text">We'll never share your email</span>
<span id="email-error" class="error-message" hidden></span>
</div>
<!-- Password with real-time validation -->
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
aria-describedby="pwd-requirements pwd-error"
aria-invalid="false">
<div id="pwd-requirements">
<ul>
<li id="pwd-length" aria-invalid="true">At least 8 characters</li>
<li id="pwd-uppercase" aria-invalid="true">One uppercase letter</li>
<li id="pwd-number" aria-invalid="true">One number</li>
</ul>
</div>
<div id="pwd-error" class="error-message" aria-live="polite" hidden></div>
</div>
<button type="submit">Create Account</button>
</form>
<style>
.error-message {
color: #d32f2f;
font-size: 0.875rem;
margin-top: 4px;
display: flex;
align-items: center;
}
.error-message::before {
content: "⚠ ";
margin-right: 4px;
}
[aria-invalid="true"] {
border-color: #d32f2f;
border-width: 2px;
}
[aria-invalid="false"]::before {
content: "✓";
color: #2e7d32;
}
</style>
<script>
const form = document.getElementById('signup-form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');
const errorSummary = document.getElementById('error-summary');
const errorList = document.getElementById('error-list');
// Email validation on blur
emailInput.addEventListener('blur', () => {
validateEmail();
});
function validateEmail() {
const email = emailInput.value.trim();
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
showFieldError(emailInput, emailError, 'Email address is required');
return false;
} else if (!emailRegex.test(email)) {
showFieldError(emailInput, emailError, 'Please enter a valid email address');
return false;
} else {
clearFieldError(emailInput, emailError);
return true;
}
}
function showFieldError(input, errorElement, message) {
input.setAttribute('aria-invalid', 'true');
errorElement.textContent = message;
errorElement.hidden = false;
}
function clearFieldError(input, errorElement) {
input.setAttribute('aria-invalid', 'false');
errorElement.textContent = '';
errorElement.hidden = true;
}
// Form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
const errors = [];
// Validate all fields
if (!validateEmail()) {
errors.push({ field: 'email', message: 'Email address is invalid' });
}
if (errors.length > 0) {
// Show error summary
errorList.innerHTML = errors.map(err =>
`<li><a href="#${err.field}">${err.message}</a></li>`
).join('');
errorSummary.hidden = false;
// Focus first error field
const firstErrorField = document.getElementById(errors[0].field);
firstErrorField.focus();
} else {
// Submit form
errorSummary.hidden = true;
console.log('Form is valid, submitting...');
}
});
</script>
Warning: Always set
aria-invalid="false" initially and update to
"true" only when validation fails. Set back to "false" when user corrects the error.
| Error Message Best Practice | Good Example | Bad Example |
|---|---|---|
| Be Specific | "Email must include @ symbol" | "Invalid input" |
| Provide Solution | "Password must be at least 8 characters. Currently: 5" | "Password too short" |
| Use Plain Language | "Please enter your phone number" | "Field validation failed: ERR_TEL_001" |
| Indicate Field | "Email address is required" | "This field is required" |
| Avoid Jargon | "Enter a date in MM/DD/YYYY format" | "Date regex mismatch" |
4. Required Field Indicators (required, aria-required)
| Method | Implementation | Accessibility | Visual |
|---|---|---|---|
| HTML5 required | <input required> |
Browser validation, auto aria-required |
No visual by default |
| aria-required | <input aria-required="true"> |
Announces "required" to screen readers | No visual by default |
| Asterisk (*) | <abbr title="required">*</abbr> |
Must explain at form start | Common visual indicator |
| Text Label | <span>(required)</span> |
Best for clarity | More space, very clear |
| Hidden Text | <span class="sr-only">required</span> |
Screen reader only announcement | No visual (relies on asterisk or other) |
Example: Required field patterns
<!-- Best: Combine HTML5 required with visible indicator -->
<form>
<p>Fields marked with <abbr title="required">*</abbr> are required.</p>
<!-- Method 1: Asterisk with aria-label -->
<label for="name">
Full Name
<span aria-label="required" class="required">*</span>
</label>
<input type="text" id="name" required>
<!-- Method 2: Text in parentheses -->
<label for="email">
Email Address <span class="required-text">(required)</span>
</label>
<input type="email" id="email" required aria-required="true">
<!-- Method 3: Screen reader text + visual asterisk -->
<label for="phone">
Phone Number
<abbr title="required" aria-label="required">*</abbr>
</label>
<input type="tel" id="phone" required>
<!-- Optional field (clearly marked) -->
<label for="company">
Company Name <span class="optional-text">(optional)</span>
</label>
<input type="text" id="company">
</form>
<style>
.required {
color: #d32f2f;
font-weight: bold;
}
.required-text {
color: #666;
font-size: 0.875rem;
font-weight: normal;
}
.optional-text {
color: #999;
font-size: 0.875rem;
font-weight: normal;
font-style: italic;
}
/* CSS to show asterisk on required inputs without manual markup */
label:has(+ input[required])::after,
label:has(+ select[required])::after,
label:has(+ textarea[required])::after {
content: " *";
color: #d32f2f;
}
</style>
Note: Always explain required field indicators at the start of the form. Don't rely solely on
color or asterisks - provide text equivalents for screen reader users.
5. Custom Form Controls (Accessible Checkboxes, Radio Buttons)
| Custom Control | ARIA Role | Required Attributes | Keyboard Interaction |
|---|---|---|---|
| Custom Checkbox | role="checkbox" |
aria-checked, tabindex="0" |
Space to toggle |
| Custom Radio | role="radio" |
aria-checked, tabindex (roving) |
Arrow keys navigate, Space selects |
| Toggle Switch | role="switch" |
aria-checked, tabindex="0" |
Space to toggle |
| Custom Select | role="combobox" |
aria-expanded, aria-controls, aria-activedescendant |
Arrow keys, Enter, Escape |
| Range Slider | role="slider" |
aria-valuemin, aria-valuemax, aria-valuenow |
Arrow keys adjust value |
| Spin Button | role="spinbutton" |
aria-valuemin, aria-valuemax, aria-valuenow |
Up/Down arrows, Page Up/Down |
Example: Custom checkbox implementation
<!-- HTML -->
<div class="custom-checkbox">
<div
role="checkbox"
aria-checked="false"
aria-labelledby="terms-label"
tabindex="0"
id="terms-checkbox"
class="checkbox-control">
<span class="checkbox-icon" aria-hidden="true"></span>
</div>
<label id="terms-label" for="terms-checkbox">
I agree to the terms and conditions
</label>
</div>
<style>
.custom-checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.checkbox-control {
width: 20px;
height: 20px;
border: 2px solid #666;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-control:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
.checkbox-control[aria-checked="true"] {
background: #0066cc;
border-color: #0066cc;
}
.checkbox-control[aria-checked="true"] .checkbox-icon::after {
content: "✓";
color: white;
font-weight: bold;
}
/* Disabled state */
.checkbox-control[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<script>
class CustomCheckbox {
constructor(element) {
this.element = element;
this.checked = element.getAttribute('aria-checked') === 'true';
// Event listeners
this.element.addEventListener('click', () => this.toggle());
this.element.addEventListener('keydown', (e) => this.handleKeydown(e));
}
toggle() {
if (this.element.getAttribute('aria-disabled') === 'true') return;
this.checked = !this.checked;
this.element.setAttribute('aria-checked', this.checked);
// Dispatch change event
this.element.dispatchEvent(new CustomEvent('change', {
detail: { checked: this.checked }
}));
}
handleKeydown(e) {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
this.toggle();
}
}
}
// Initialize all custom checkboxes
document.querySelectorAll('[role="checkbox"]').forEach(el => {
new CustomCheckbox(el);
});
</script>
Example: Custom toggle switch
<div class="toggle-container">
<button
role="switch"
aria-checked="false"
aria-labelledby="notifications-label"
id="notifications-toggle"
class="toggle-switch">
<span class="toggle-slider" aria-hidden="true"></span>
</button>
<span id="notifications-label">Enable notifications</span>
</div>
<style>
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: #ccc;
border: none;
border-radius: 12px;
cursor: pointer;
transition: background 0.3s;
}
.toggle-switch[aria-checked="true"] {
background: #4caf50;
}
.toggle-slider {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: transform 0.3s;
}
.toggle-switch[aria-checked="true"] .toggle-slider {
transform: translateX(20px);
}
.toggle-switch:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
</style>
<script>
document.getElementById('notifications-toggle').addEventListener('click', function() {
const isChecked = this.getAttribute('aria-checked') === 'true';
this.setAttribute('aria-checked', !isChecked);
});
</script>
Warning: Only create custom form controls when native HTML elements can't meet your needs.
Native controls are tested across browsers/assistive tech and handle edge cases you might miss.
| Custom Control Checklist | Requirement | Test Method |
|---|---|---|
| Keyboard Accessible | All interactions work with keyboard only | Unplug mouse and test |
| Correct Role | Use appropriate ARIA role | Screen reader announces role correctly |
| State Management | ARIA states update dynamically | Screen reader announces state changes |
| Focus Indicator | Clear focus outline on keyboard focus | Tab to element and verify visibility |
| Label Association | Label properly associated with control | Screen reader announces label |
| Touch/Mobile Support | Touch targets ≥ 44x44px | Test on mobile device |
6. Form Submission Feedback
| Feedback Type | Trigger | Implementation | ARIA |
|---|---|---|---|
| Loading State | Form submitted, processing | Disable submit button, show spinner | aria-busy="true", aria-live="polite" |
| Success Message | Form processed successfully | Show confirmation message or redirect | role="status" or role="alert" |
| Error Alert | Server error or validation failure | Show error summary, focus first error | role="alert", aria-live="assertive" |
| Progress Indicator | Multi-step form | Progress bar or step indicator | aria-valuenow, aria-valuemin/max |
| Autosave Confirmation | Auto-save triggered | Brief "Saved" message | role="status", aria-live="polite" |
Example: Complete form submission feedback
<form id="contact-form">
<!-- Form fields here -->
<button type="submit" id="submit-btn">
<span class="btn-text">Send Message</span>
<span class="btn-spinner" hidden aria-hidden="true">
<svg class="spinner">...</svg>
</span>
</button>
<!-- Status messages -->
<div
id="form-status"
role="status"
aria-live="polite"
aria-atomic="true"
class="status-message"
hidden>
</div>
</form>
<script>
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const btnText = submitBtn.querySelector('.btn-text');
const btnSpinner = submitBtn.querySelector('.btn-spinner');
const statusDiv = document.getElementById('form-status');
form.addEventListener('submit', async (e) => {
e.preventDefault();
// Set loading state
setLoadingState(true);
try {
// Simulate API call
const response = await fetch('/api/contact', {
method: 'POST',
body: new FormData(form)
});
if (response.ok) {
showSuccess('Message sent successfully! We\'ll respond within 24 hours.');
form.reset();
} else {
showError('Failed to send message. Please try again.');
}
} catch (error) {
showError('Network error. Please check your connection and try again.');
} finally {
setLoadingState(false);
}
});
function setLoadingState(isLoading) {
if (isLoading) {
submitBtn.disabled = true;
submitBtn.setAttribute('aria-busy', 'true');
btnText.textContent = 'Sending...';
btnSpinner.hidden = false;
btnSpinner.removeAttribute('aria-hidden');
} else {
submitBtn.disabled = false;
submitBtn.setAttribute('aria-busy', 'false');
btnText.textContent = 'Send Message';
btnSpinner.hidden = true;
btnSpinner.setAttribute('aria-hidden', 'true');
}
}
function showSuccess(message) {
statusDiv.className = 'status-message success';
statusDiv.innerHTML = `
<svg aria-hidden="true">...checkmark...</svg>
${message}
`;
statusDiv.hidden = false;
// Auto-hide after 5 seconds
setTimeout(() => {
statusDiv.hidden = true;
}, 5000);
}
function showError(message) {
statusDiv.className = 'status-message error';
statusDiv.setAttribute('role', 'alert');
statusDiv.innerHTML = `
<svg aria-hidden="true">...error icon...</svg>
${message}
`;
statusDiv.hidden = false;
}
// Auto-save example
let autoSaveTimeout;
form.addEventListener('input', () => {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(autoSave, 2000);
});
async function autoSave() {
const saveStatus = document.createElement('div');
saveStatus.setAttribute('role', 'status');
saveStatus.setAttribute('aria-live', 'polite');
saveStatus.className = 'autosave-indicator';
saveStatus.textContent = 'Saving draft...';
document.body.appendChild(saveStatus);
// Simulate save
await new Promise(resolve => setTimeout(resolve, 500));
saveStatus.textContent = 'Draft saved';
setTimeout(() => saveStatus.remove(), 2000);
}
</script>
<style>
.status-message {
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.status-message.success {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #4caf50;
}
.status-message.error {
background: #ffebee;
color: #c62828;
border: 1px solid #f44336;
}
.btn-spinner {
display: inline-block;
width: 16px;
height: 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 1s linear infinite;
}
button[aria-busy="true"] {
opacity: 0.7;
cursor: wait;
}
.autosave-indicator {
position: fixed;
bottom: 20px;
right: 20px;
padding: 8px 16px;
background: #333;
color: white;
border-radius: 4px;
font-size: 0.875rem;
}
</style>
| Submission State | Button State | User Action | Announcement |
|---|---|---|---|
| Ready | Enabled, "Submit" | Can submit form | None |
| Submitting | Disabled, "Submitting..." + spinner | Wait for response | "Submitting form" (via aria-live) |
| Success | Re-enabled or hidden | View confirmation or continue | "Form submitted successfully" (role="status") |
| Error | Re-enabled | Fix errors and resubmit | "Error: [message]" (role="alert") |
| Auto-saving | Enabled (background save) | Continue editing | "Draft saved" (role="status", polite) |
Note: Use
role="status" (polite) for non-critical updates like autosave. Use
role="alert" (assertive) for errors that require immediate attention.
Form Accessibility Quick Reference
- Every input must have an associated
<label>- never rely on placeholder alone - Use
aria-describedbyfor help text,aria-labelledbyfor complex labels - Group related fields with
<fieldset>and<legend> - Set
aria-invalid="true"on fields with errors and link to error messages - Show error summary at form top with
role="alert"and focus first error - Mark required fields with
requiredattribute + visual indicator (*, text) - Custom controls need proper ARIA roles, states, and keyboard interaction
- Provide clear feedback during and after submission with
aria-liveregions - Disable submit button during processing and show loading state
- Test forms with keyboard only and screen readers