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
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
<!-- 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
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.875 rem ;
margin-top : 4 px ;
display : flex ;
align-items : center ;
}
.error-message::before {
content : "⚠ " ;
margin-right : 4 px ;
}
[ aria-invalid = "true" ] {
border-color : #d32f2f ;
border-width : 2 px ;
}
[ 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
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.875 rem ;
font-weight : normal ;
}
.optional-text {
color : #999 ;
font-size : 0.875 rem ;
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.
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 : 8 px ;
}
.checkbox-control {
width : 20 px ;
height : 20 px ;
border : 2 px solid #666 ;
border-radius : 4 px ;
display : flex ;
align-items : center ;
justify-content : center ;
cursor : pointer ;
transition : all 0.2 s ;
}
.checkbox-control:focus {
outline : 2 px solid #0066cc ;
outline-offset : 2 px ;
}
.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 : 44 px ;
height : 24 px ;
background : #ccc ;
border : none ;
border-radius : 12 px ;
cursor : pointer ;
transition : background 0.3 s ;
}
.toggle-switch [ aria-checked = "true" ] {
background : #4caf50 ;
}
.toggle-slider {
position : absolute ;
top : 2 px ;
left : 2 px ;
width : 20 px ;
height : 20 px ;
background : white ;
border-radius : 50 % ;
transition : transform 0.3 s ;
}
.toggle-switch [ aria-checked = "true" ] .toggle-slider {
transform : translateX ( 20 px );
}
.toggle-switch:focus {
outline : 2 px solid #0066cc ;
outline-offset : 2 px ;
}
</ 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
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"
< 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 : 12 px 16 px ;
margin : 16 px 0 ;
border-radius : 4 px ;
display : flex ;
align-items : center ;
gap : 8 px ;
}
.status-message.success {
background : #e8f5e9 ;
color : #2e7d32 ;
border : 1 px solid #4caf50 ;
}
.status-message.error {
background : #ffebee ;
color : #c62828 ;
border : 1 px solid #f44336 ;
}
.btn-spinner {
display : inline-block ;
width : 16 px ;
height : 16 px ;
}
@keyframes spin {
to { transform : rotate ( 360 deg ); }
}
.spinner {
animation : spin 1 s linear infinite ;
}
button [ aria-busy = "true" ] {
opacity : 0.7 ;
cursor : wait ;
}
.autosave-indicator {
position : fixed ;
bottom : 20 px ;
right : 20 px ;
padding : 8 px 16 px ;
background : #333 ;
color : white ;
border-radius : 4 px ;
font-size : 0.875 rem ;
}
</ 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.
Every input must have an associated <label> - never rely on placeholder alone
Use aria-describedby for help text, aria-labelledby for 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 required attribute + visual indicator (*, text)
Custom controls need proper ARIA roles, states, and keyboard interaction
Provide clear feedback during and after submission with aria-live regions
Disable submit button during processing and show loading state
Test forms with keyboard only and screen readers