Skip to main content

Complete ARIA & Keyboard Navigation

A comprehensive guide to ARIA roles, states, properties, and keyboard navigation patterns for building accessible web applications.

Table of Contents

  1. ARIA Roles
  2. ARIA States & Properties
  3. Accessible Notes & Text Areas
  4. Keyboard Navigation Patterns
  5. Live Regions & Dynamic Content
  6. Form Accessibility
  7. Modal & Dialog Patterns
  8. Testing & Validation

ARIA Roles

ARIA roles define what an element is semantically to assistive technologies. They communicate the purpose and behavior of elements.

Landmark Roles

Landmark roles help users navigate page structure and find content quickly.

<!-- Site-wide header -->
<header role="banner">
<h1>My Website</h1>
<nav role="navigation" aria-label="Main menu">
<ul>
<li><a href="/home">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header>

<!-- Main content area -->
<main role="main">
<article>
<h2>Article Title</h2>
<p>Article content...</p>
</article>
</main>

<!-- Sidebar content -->
<aside role="complementary" aria-labelledby="sidebar-heading">
<h3 id="sidebar-heading">Related Links</h3>
<ul>
<li><a href="#">Link 1</a></li>
<li><a href="#">Link 2</a></li>
</ul>
</aside>

<!-- Site footer -->
<footer role="contentinfo">
<p>&copy; 2025 My Website</p>
</footer>

💡 Best Practice: Use semantic HTML elements (<header>, <nav>, <main>, <aside>, <footer>) which have implicit ARIA roles. Only add explicit roles when semantic HTML isn't sufficient.

Widget Roles

Widget roles define interactive components and their expected behaviors.

<!-- Custom button -->
<div role="button"
tabindex="0"
aria-pressed="false"
onkeydown="handleButtonKeydown(event)"
onclick="toggleButton()">
Toggle Setting
</div>

<!-- Custom checkbox -->
<div role="checkbox"
tabindex="0"
aria-checked="false"
aria-labelledby="custom-checkbox-label">
<span id="custom-checkbox-label">Enable notifications</span>
</div>

<!-- Dialog/Modal -->
<div role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
aria-modal="true">
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-description">Are you sure you want to delete this item?</p>
<button>Cancel</button>
<button>Delete</button>
</div>

<!-- Tab interface -->
<div role="tablist" aria-label="Settings tabs">
<button role="tab"
aria-selected="true"
aria-controls="general-panel"
id="general-tab">General</button>
<button role="tab"
aria-selected="false"
aria-controls="privacy-panel"
id="privacy-tab">Privacy</button>
</div>

<div role="tabpanel"
id="general-panel"
aria-labelledby="general-tab">
<h3>General Settings</h3>
<!-- Panel content -->
</div>

Document Structure Roles

<!-- Article with proper heading structure -->
<article role="article">
<header>
<h1>Article Title</h1>
<p>Published on <time datetime="2025-01-15">January 15, 2025</time></p>
</header>

<div role="region" aria-labelledby="section1">
<h2 id="section1">Introduction</h2>
<p>Content...</p>
</div>
</article>

<!-- Data table -->
<table role="table" aria-label="Sales data">
<thead>
<tr role="row">
<th role="columnheader">Month</th>
<th role="columnheader">Sales</th>
<th role="columnheader">Growth</th>
</tr>
</thead>
<tbody>
<tr role="row">
<td role="cell">January</td>
<td role="cell">$10,000</td>
<td role="cell">+5%</td>
</tr>
</tbody>
</table>

<!-- List with custom styling -->
<ul role="list" aria-label="Feature list">
<li role="listitem">Feature 1</li>
<li role="listitem">Feature 2</li>
<li role="listitem">Feature 3</li>
</ul>

ARIA States & Properties

ARIA states and properties describe the current condition and relationships of elements.

Common States

States describe the current condition of an element and can change frequently.

<!-- Disabled state -->
<button aria-disabled="true" onclick="handleClick(event)">
Save (processing...)
</button>

<!-- Expanded/Collapsed states -->
<button aria-expanded="false"
aria-controls="menu-items"
onclick="toggleMenu()">
Menu <span aria-hidden="true"></span>
</button>
<ul id="menu-items" hidden>
<li><a href="#">Item 1</a></li>
<li><a href="#">Item 2</a></li>
</ul>

<!-- Checked states -->
<div role="checkbox"
aria-checked="false"
tabindex="0"
onclick="toggleCheck(this)">
<span class="checkbox-icon" aria-hidden="true"></span>
Enable feature
</div>

<div role="checkbox"
aria-checked="mixed"
tabindex="0">
<span class="checkbox-icon" aria-hidden="true"></span>
Select all items (some selected)
</div>

<!-- Selected state -->
<ul role="listbox" aria-label="Color options">
<li role="option"
aria-selected="false"
tabindex="0">Red</li>
<li role="option"
aria-selected="true"
tabindex="-1">Blue</li>
<li role="option"
aria-selected="false"
tabindex="-1">Green</li>
</ul>

<!-- Hidden state -->
<div aria-hidden="true" class="decorative-icon">🎉</div>
<span class="sr-only">Celebration complete!</span>

Properties (Relationships & Descriptions)

Properties describe relationships between elements and provide additional context.

<!-- Labeling -->
<input type="email"
id="email-input"
aria-label="Email address"
aria-required="true"
aria-invalid="false">

<!-- Label by reference -->
<h2 id="billing-heading">Billing Information</h2>
<fieldset aria-labelledby="billing-heading">
<input type="text" placeholder="Card number">
<input type="text" placeholder="Expiry date">
</fieldset>

<!-- Described by reference -->
<input type="password"
id="password"
aria-describedby="password-help password-strength">
<div id="password-help">
Password must be at least 8 characters long
</div>
<div id="password-strength" aria-live="polite">
Password strength: Weak
</div>

<!-- Controls relationship -->
<button aria-controls="video-player"
aria-pressed="false"
onclick="togglePlayback()">
<span aria-hidden="true">▶️</span>
Play video
</button>
<video id="video-player" src="video.mp4"></video>

<!-- Owns relationship -->
<div role="combobox"
aria-owns="suggestions-list"
aria-expanded="false">
<input type="text" aria-autocomplete="list">
<ul id="suggestions-list" role="listbox" hidden>
<li role="option">Suggestion 1</li>
<li role="option">Suggestion 2</li>
</ul>
</div>

<!-- Flow to (reading order) -->
<div id="step1">
<h3>Step 1: Enter details</h3>
<input type="text" aria-flowto="step2">
</div>
<div id="step2" aria-flowto="step3">
<h3>Step 2: Review</h3>
<!-- content -->
</div>

Accessible Notes & Text Areas

Creating accessible text input areas for notes, comments, and long-form content.

Basic Textarea Implementation

<!-- Semantic HTML approach (preferred) -->
<div class="form-group">
<label for="notes">Meeting Notes</label>
<textarea id="notes"
name="notes"
rows="6"
cols="50"
aria-required="true"
aria-describedby="notes-help notes-count"
placeholder="Enter your notes here..."></textarea>

<div id="notes-help" class="help-text">
Include key discussion points and action items
</div>

<div id="notes-count" class="character-count" aria-live="polite">
0 / 500 characters
</div>
</div>

Rich Text Editor (Contenteditable)

When you need more than basic textarea functionality:

<div class="rich-editor">
<label id="editor-label">Article Content</label>

<!-- Toolbar -->
<div role="toolbar"
aria-label="Formatting options"
aria-controls="editor-content">
<button type="button"
aria-pressed="false"
onclick="toggleFormat('bold')"
title="Bold (Ctrl+B)">
<strong aria-hidden="true">B</strong>
<span class="sr-only">Bold</span>
</button>

<button type="button"
aria-pressed="false"
onclick="toggleFormat('italic')"
title="Italic (Ctrl+I)">
<em aria-hidden="true">I</em>
<span class="sr-only">Italic</span>
</button>
</div>

<!-- Editor content -->
<div id="editor-content"
role="textbox"
aria-multiline="true"
aria-labelledby="editor-label"
aria-describedby="editor-help"
contenteditable="true"
spellcheck="true"
tabindex="0"
onkeydown="handleEditorKeydown(event)"
oninput="updateCharCount()">
</div>

<div id="editor-help" class="help-text">
Use Ctrl+B for bold, Ctrl+I for italic. Press Shift+F10 for formatting options.
</div>
</div>

Error States and Validation

<div class="form-group">
<label for="required-notes">Project Description *</label>

<textarea id="required-notes"
aria-required="true"
aria-invalid="true"
aria-describedby="notes-error notes-help">
</textarea>

<!-- Error message -->
<div id="notes-error"
role="alert"
class="error-message"
aria-atomic="true">
<span class="error-icon" aria-hidden="true">⚠️</span>
Project description is required and must be at least 10 characters long.
</div>

<!-- Help text -->
<div id="notes-help" class="help-text">
Describe the project goals, timeline, and key deliverables.
</div>
</div>

Advanced Notes Features

<div class="advanced-notes-editor">
<!-- Header with metadata -->
<div class="notes-header">
<h3 id="notes-title">Session Notes</h3>
<div class="notes-meta" aria-label="Note metadata">
<span>Last saved: <time id="last-saved">2 minutes ago</time></span>
<span>Word count: <span id="word-count">247</span></span>
</div>
</div>

<!-- Main editor -->
<div class="editor-container">
<textarea id="advanced-notes"
aria-labelledby="notes-title"
aria-describedby="editor-status keyboard-shortcuts"
spellcheck="true"
autocorrect="on"
autocapitalize="sentences"
onkeydown="handleAdvancedKeydown(event)"
oninput="autoSave()"
onfocus="showKeyboardHelp()"
onblur="hideKeyboardHelp()">
</textarea>

<!-- Auto-save status -->
<div id="editor-status"
aria-live="polite"
aria-atomic="false"
class="save-status">
All changes saved
</div>
</div>

<!-- Keyboard shortcuts help -->
<div id="keyboard-shortcuts"
class="keyboard-help"
role="region"
aria-label="Keyboard shortcuts">
<h4>Keyboard Shortcuts</h4>
<dl>
<dt>Ctrl + S</dt>
<dd>Save notes</dd>
<dt>Ctrl + Z</dt>
<dd>Undo</dd>
<dt>Ctrl + Y</dt>
<dd>Redo</dd>
<dt>Ctrl + F</dt>
<dd>Find in notes</dd>
</dl>
</div>
</div>

Keyboard Navigation Patterns

Comprehensive keyboard navigation patterns for different UI components.

Focus Management Principles

// Focus management utilities
class FocusManager {
constructor() {
this.focusableSelectors = [
'a[href]',
'button',
'input',
'select',
'textarea',
'[tabindex]',
'[contenteditable="true"]'
].join(', ');
}

getFocusableElements(container) {
const elements = container.querySelectorAll(this.focusableSelectors);
return Array.from(elements).filter(el => {
return !el.disabled &&
!el.hasAttribute('aria-hidden') &&
el.tabIndex !== -1;
});
}

trapFocus(container) {
const focusableElements = this.getFocusableElements(container);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

container.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
});
}
}

Button Navigation

<div class="button-group" role="group" aria-label="Document actions">
<button type="button"
onclick="saveDocument()"
onkeydown="handleButtonKeydown(event, 'save')">
Save
</button>

<button type="button"
onclick="previewDocument()"
onkeydown="handleButtonKeydown(event, 'preview')">
Preview
</button>

<button type="button"
onclick="publishDocument()"
onkeydown="handleButtonKeydown(event, 'publish')">
Publish
</button>
</div>

<script>
function handleButtonKeydown(event, action) {
// Enter and Space activate buttons
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.target.click();
}

// Arrow key navigation within button group
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
const buttons = [...event.target.parentNode.querySelectorAll('button')];
const currentIndex = buttons.indexOf(event.target);
let nextIndex;

if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % buttons.length;
} else {
nextIndex = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1;
}

buttons[nextIndex].focus();
event.preventDefault();
}
}
</script>
<div class="menu-container">
<button id="menu-trigger"
aria-haspopup="true"
aria-expanded="false"
aria-controls="main-menu"
onkeydown="handleMenuTriggerKeydown(event)"
onclick="toggleMenu()">
File <span aria-hidden="true"></span>
</button>

<ul id="main-menu"
role="menu"
aria-labelledby="menu-trigger"
hidden
onkeydown="handleMenuKeydown(event)">

<li role="menuitem" tabindex="-1">
<button type="button" onclick="newDocument()">
New <kbd aria-hidden="true">Ctrl+N</kbd>
</button>
</li>

<li role="menuitem" tabindex="-1">
<button type="button" onclick="openDocument()">
Open <kbd aria-hidden="true">Ctrl+O</kbd>
</button>
</li>

<li role="separator" aria-hidden="true"></li>

<li role="menuitem"
aria-haspopup="true"
aria-expanded="false"
tabindex="-1">
<button type="button" onclick="toggleRecentMenu()">
Recent Files <span aria-hidden="true"></span>
</button>

<!-- Submenu -->
<ul role="menu" hidden>
<li role="menuitem" tabindex="-1">
<button type="button">Document1.txt</button>
</li>
<li role="menuitem" tabindex="-1">
<button type="button">Document2.txt</button>
</li>
</ul>
</li>
</ul>
</div>

<script>
function handleMenuTriggerKeydown(event) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault();
openMenu();
break;

case 'ArrowUp':
event.preventDefault();
openMenu(true); // Focus last item
break;
}
}

function handleMenuKeydown(event) {
const menuItems = [...event.target.closest('[role="menu"]')
.querySelectorAll('[role="menuitem"]:not([aria-hidden="true"])')];
const currentIndex = menuItems.indexOf(event.target.closest('[role="menuitem"]'));

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % menuItems.length;
menuItems[nextIndex].querySelector('button').focus();
break;

case 'ArrowUp':
event.preventDefault();
const prevIndex = currentIndex === 0 ? menuItems.length - 1 : currentIndex - 1;
menuItems[prevIndex].querySelector('button').focus();
break;

case 'Enter':
case ' ':
event.preventDefault();
event.target.click();
break;

case 'Escape':
closeMenu();
document.getElementById('menu-trigger').focus();
break;

case 'ArrowRight':
// Handle submenu navigation
const submenu = event.target.closest('[role="menuitem"]').querySelector('[role="menu"]');
if (submenu) {
event.preventDefault();
openSubmenu(submenu);
}
break;

case 'ArrowLeft':
// Close submenu or return to parent menu
const parentMenu = event.target.closest('[role="menu"]').parentElement.closest('[role="menu"]');
if (parentMenu) {
event.preventDefault();
closeSubmenu();
// Focus parent menu item
}
break;
}
}
</script>

Tab Navigation

<div class="tab-container">
<div role="tablist"
aria-label="Settings sections"
onkeydown="handleTabListKeydown(event)">

<button role="tab"
id="general-tab"
aria-selected="true"
aria-controls="general-panel"
tabindex="0">
General
</button>

<button role="tab"
id="privacy-tab"
aria-selected="false"
aria-controls="privacy-panel"
tabindex="-1">
Privacy
</button>

<button role="tab"
id="security-tab"
aria-selected="false"
aria-controls="security-panel"
tabindex="-1">
Security
</button>
</div>

<div role="tabpanel"
id="general-panel"
aria-labelledby="general-tab"
tabindex="0">
<h3>General Settings</h3>
<label>
<input type="checkbox"> Enable notifications
</label>
</div>

<div role="tabpanel"
id="privacy-panel"
aria-labelledby="privacy-tab"
tabindex="0"
hidden>
<h3>Privacy Settings</h3>
<label>
<input type="checkbox"> Share usage data
</label>
</div>
</div>

<script>
function handleTabListKeydown(event) {
const tabs = [...event.target.closest('[role="tablist"]').querySelectorAll('[role="tab"]')];
const currentIndex = tabs.indexOf(event.target);
let nextIndex;

switch (event.key) {
case 'ArrowRight':
case 'ArrowLeft':
event.preventDefault();

if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length;
} else {
nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
}

selectTab(tabs[nextIndex]);
break;

case 'Home':
event.preventDefault();
selectTab(tabs[0]);
break;

case 'End':
event.preventDefault();
selectTab(tabs[tabs.length - 1]);
break;
}
}

function selectTab(tab) {
// Update ARIA states
const tablist = tab.closest('[role="tablist"]');
const tabs = [...tablist.querySelectorAll('[role="tab"]')];

tabs.forEach(t => {
t.setAttribute('aria-selected', 'false');
t.tabIndex = -1;
});

tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
tab.focus();

// Show corresponding panel
const panels = [...document.querySelectorAll('[role="tabpanel"]')];
panels.forEach(p => p.hidden = true);

const targetPanel = document.getElementById(tab.getAttribute('aria-controls'));
if (targetPanel) {
targetPanel.hidden = false;
}
}
</script>

Listbox/Combobox Navigation

<div class="combobox-container">
<label for="country-input">Choose a country</label>

<div role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="country-listbox">

<input type="text"
id="country-input"
aria-autocomplete="list"
aria-controls="country-listbox"
onkeydown="handleComboboxKeydown(event)"
oninput="filterOptions(event)"
onfocus="showOptions()"
onblur="hideOptions()">
</div>

<ul id="country-listbox"
role="listbox"
aria-label="Country options"
hidden
onkeydown="handleListboxKeydown(event)">

<li role="option"
id="option-us"
aria-selected="false">United States</li>
<li role="option"
id="option-ca"
aria-selected="false">Canada</li>
<li role="option"
id="option-uk"
aria-selected="false">United Kingdom</li>
</ul>
</div>

<script>
function handleComboboxKeydown(event) {
const listbox = document.getElementById('country-listbox');
const options = [...listbox.querySelectorAll('[role="option"]:not([hidden])')];

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[0]);
break;

case 'ArrowUp':
event.preventDefault();
if (listbox.hidden) {
showOptions();
}
focusOption(options[options.length - 1]);
break;

case 'Escape':
hideOptions();
break;

case 'Enter':
if (!listbox.hidden) {
const selectedOption = listbox.querySelector('[aria-selected="true"]');
if (selectedOption) {
selectOption(selectedOption);
}
}
break;
}
}

function handleListboxKeydown(event) {
const options = [...event.target.closest('[role="listbox"]')
.querySelectorAll('[role="option"]:not([hidden])')];
const currentIndex = options.indexOf(event.target);

switch (event.key) {
case 'ArrowDown':
event.preventDefault();
const nextIndex = (currentIndex + 1) % options.length;
focusOption(options[nextIndex]);
break;

case 'ArrowUp':
event.preventDefault();
const prevIndex = currentIndex === 0 ? options.length - 1 : currentIndex - 1;
focusOption(options[prevIndex]);
break;

case 'Enter':
case ' ':
event.preventDefault();
selectOption(event.target);
break;

case 'Escape':
hideOptions();
document.getElementById('country-input').focus();
break;

case 'Home':
event.preventDefault();
focusOption(options[0]);
break;

case 'End':
event.preventDefault();
focusOption(options[options.length - 1]);
break;

// Type-ahead support
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase();
const matchingOption = options.find(option =>
option.textContent.toLowerCase().startsWith(char)
);
if (matchingOption) {
focusOption(matchingOption);
}
}
break;
}
}

function focusOption(option) {
const options = [...option.parentElement.querySelectorAll('[role="option"]')];
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));

option.setAttribute('aria-selected', 'true');
option.focus();

// Update input value for preview
const input = document.getElementById('country-input');
input.value = option.textContent;
}
</script>

Live Regions & Dynamic Content

Managing dynamic content updates for screen reader users.

Live Region Types

<!-- Polite announcements (don't interrupt) -->
<div id="status-updates"
aria-live="polite"
aria-atomic="false"
class="sr-only">
<!-- Status messages appear here -->
</div>

<!-- Assertive announcements (interrupt current speech) -->
<div id="error-announcements"
aria-live="assertive"
aria-atomic="true"
role="alert"
class="sr-only">
<!-- Critical errors appear here -->
</div>

<!-- Off - no announcements -->
<div id="debug-info"
aria-live="off"
aria-relevant="text additions removals">
<!-- Debug info that shouldn't be announced -->
</div>

<!-- Log for sequential updates -->
<div id="activity-log"
role="log"
aria-label="Recent activity"
aria-live="polite">
<ul>
<li>User John logged in at 2:30 PM</li>
<li>Document saved at 2:32 PM</li>
<!-- New items added here -->
</ul>
</div>

<!-- Timer/countdown -->
<div id="timer-display"
role="timer"
aria-live="polite"
aria-atomic="true">
<span class="time">05:00</span>
<span class="label">minutes remaining</span>
</div>

Dynamic Content Management

class LiveRegionManager {
constructor() {
this.regions = {
status: this.createRegion('polite', false),
alert: this.createRegion('assertive', true),
log: this.createRegion('polite', false, 'log')
};
}

createRegion(level, atomic, role = null) {
const region = document.createElement('div');
region.setAttribute('aria-live', level);
region.setAttribute('aria-atomic', atomic.toString());
region.className = 'sr-only';

if (role) {
region.setAttribute('role', role);
}

document.body.appendChild(region);
return region;
}

announce(message, type = 'status', delay = 100) {
// Small delay ensures screen readers catch the update
setTimeout(() => {
const region = this.regions[type];
if (region) {
region.textContent = message;

// Clear after announcement to allow repeated messages
setTimeout(() => {
region.textContent = '';
}, 1000);
}
}, delay);
}

appendToLog(message, timestamp = true) {
const logRegion = this.regions.log;
const entry = document.createElement('div');

if (timestamp) {
const time = new Date().toLocaleTimeString();
entry.textContent = `${time}: ${message}`;
} else {
entry.textContent = message;
}

logRegion.appendChild(entry);

// Limit log entries to prevent performance issues
const entries = logRegion.children;
if (entries.length > 50) {
logRegion.removeChild(entries[0]);
}
}
}

// Usage examples
const liveRegions = new LiveRegionManager();

// Status updates
function saveDocument() {
// ... save logic
liveRegions.announce('Document saved successfully');
}

// Error alerts
function handleError(error) {
liveRegions.announce(`Error: ${error.message}`, 'alert');
}

// Activity logging
function logActivity(activity) {
liveRegions.appendToLog(activity);
}

Form Validation with Live Feedback

<form novalidate onsubmit="handleFormSubmit(event)">
<div class="form-group">
<label for="username">Username *</label>
<input type="text"
id="username"
name="username"
required
minlength="3"
aria-describedby="username-help username-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)">

<div id="username-help" class="help-text">
Username must be at least 3 characters long
</div>

<div id="username-feedback"
aria-live="polite"
aria-atomic="true"
class="validation-feedback">
<!-- Validation messages appear here -->
</div>
</div>

<div class="form-group">
<label for="email">Email Address *</label>
<input type="email"
id="email"
name="email"
required
aria-describedby="email-help email-feedback"
oninput="validateField(this)"
onblur="validateField(this, true)">

<div id="email-help" class="help-text">
Enter a valid email address
</div>

<div id="email-feedback"
aria-live="polite"
class="validation-feedback">
</div>
</div>

<!-- Form-level feedback -->
<div id="form-feedback"
role="alert"
aria-live="assertive"
class="form-errors">
<!-- Form submission errors appear here -->
</div>

<button type="submit">Submit</button>
</form>

<script>
function validateField(field, showSuccess = false) {
const feedback = document.getElementById(field.id + '-feedback');
const isValid = field.checkValidity();

// Clear previous state
field.removeAttribute('aria-invalid');
feedback.textContent = '';
feedback.className = 'validation-feedback';

if (!isValid) {
field.setAttribute('aria-invalid', 'true');
feedback.className = 'validation-feedback error';
feedback.textContent = field.validationMessage;
} else if (showSuccess && field.value.trim()) {
feedback.className = 'validation-feedback success';
feedback.textContent = 'Valid';
}
}

function handleFormSubmit(event) {
event.preventDefault();

const form = event.target;
const formFeedback = document.getElementById('form-feedback');
const isValid = form.checkValidity();

if (!isValid) {
// Show form-level errors
formFeedback.innerHTML = `
<h3>Please correct the following errors:</h3>
<ul>
${Array.from(form.elements)
.filter(el => !el.checkValidity() && el.name)
.map(el => `<li>${el.labels[0]?.textContent || el.name}: ${el.validationMessage}</li>`)
.join('')}
</ul>
`;

// Focus first invalid field
const firstInvalid = form.querySelector(':invalid');
if (firstInvalid) {
firstInvalid.focus();
}
} else {
formFeedback.innerHTML = '<p>Form submitted successfully!</p>';
// Process form...
}
}
</script>

Form Accessibility

Comprehensive form accessibility patterns and techniques.

Field Grouping and Relationships

<form>
<!-- Required field indicators -->
<fieldset>
<legend>Personal Information <span class="required-note">(* indicates required fields)</span></legend>

<div class="form-row">
<div class="form-group">
<label for="first-name">
First Name *
<span class="sr-only">(required)</span>
</label>
<input type="text"
id="first-name"
name="firstName"
required
autocomplete="given-name"
aria-describedby="name-help">
</div>

<div class="form-group">
<label for="last-name">
Last Name *
<span class="sr-only">(required)</span>
</label>
<input type="text"
id="last-name"
name="lastName"
required
autocomplete="family-name">
</div>
</div>

<div id="name-help" class="help-text">
Enter your full legal name as it appears on official documents
</div>
</fieldset>

<!-- Radio button groups -->
<fieldset>
<legend>Contact Preference</legend>
<div role="radiogroup" aria-describedby="contact-help">
<label>
<input type="radio" name="contact" value="email" checked>
Email
</label>
<label>
<input type="radio" name="contact" value="phone">
Phone
</label>
<label>
<input type="radio" name="contact" value="mail">
Mail
</label>
</div>
<div id="contact-help" class="help-text">
Choose how you'd like us to contact you
</div>
</fieldset>

<!-- Checkbox groups -->
<fieldset>
<legend>Interests (select all that apply)</legend>
<div class="checkbox-group">
<label>
<input type="checkbox" name="interests" value="technology">
Technology
</label>
<label>
<input type="checkbox" name="interests" value="design">
Design
</label>
<label>
<input type="checkbox" name="interests" value="business">
Business
</label>
</div>
</fieldset>

<!-- Complex input with multiple parts -->
<fieldset>
<legend>Phone Number</legend>
<div class="phone-input" role="group" aria-labelledby="phone-legend">
<label for="phone-country" class="sr-only">Country code</label>
<select id="phone-country" name="phoneCountry" aria-describedby="phone-help">
<option value="+1">+1 (US)</option>
<option value="+44">+44 (UK)</option>
<option value="+33">+33 (FR)</option>
</select>

<label for="phone-number" class="sr-only">Phone number</label>
<input type="tel"
id="phone-number"
name="phoneNumber"
placeholder="(555) 123-4567"
autocomplete="tel"
aria-describedby="phone-help">
</div>
<div id="phone-help" class="help-text">
Include area code for US numbers
</div>
</fieldset>
</form>

Advanced Input Types

<div class="advanced-inputs">
<!-- Date picker -->
<div class="form-group">
<label for="birth-date">Date of Birth</label>
<input type="date"
id="birth-date"
name="birthDate"
min="1900-01-01"
max="2023-12-31"
aria-describedby="date-help"
onchange="validateAge(this)">
<div id="date-help" class="help-text">
Must be 18 years or older
</div>
</div>

<!-- Range slider -->
<div class="form-group">
<label for="salary-range">Expected Salary Range</label>
<div class="range-container">
<input type="range"
id="salary-range"
name="salaryRange"
min="30000"
max="200000"
step="5000"
value="75000"
aria-describedby="salary-help salary-value"
oninput="updateRangeValue(this)">
<output id="salary-value" for="salary-range" aria-live="polite">
$75,000
</output>
</div>
<div id="salary-help" class="help-text">
Adjust slider to set your expected salary range
</div>
</div>

<!-- File upload -->
<div class="form-group">
<label for="resume-upload">Upload Resume</label>
<input type="file"
id="resume-upload"
name="resume"
accept=".pdf,.doc,.docx"
aria-describedby="file-help file-status"
onchange="handleFileUpload(this)">
<div id="file-help" class="help-text">
Accepted formats: PDF, DOC, DOCX (max 5MB)
</div>
<div id="file-status" aria-live="polite" class="file-status">
<!-- Upload status appears here -->
</div>
</div>

<!-- Multi-step progress -->
<div class="progress-container">
<div role="progressbar"
aria-valuenow="2"
aria-valuemin="1"
aria-valuemax="4"
aria-labelledby="progress-label">
<div id="progress-label">Step 2 of 4: Contact Information</div>
<div class="progress-bar">
<div class="progress-fill" style="width: 50%"></div>
</div>
</div>
</div>
</div>

<script>
function updateRangeValue(slider) {
const output = document.getElementById('salary-value');
const value = parseInt(slider.value);
output.textContent = `${value.toLocaleString()}`;
}

function handleFileUpload(input) {
const status = document.getElementById('file-status');
const file = input.files[0];

if (file) {
if (file.size > 5 * 1024 * 1024) { // 5MB
status.innerHTML = '<span class="error">File too large. Maximum size is 5MB.</span>';
input.value = '';
} else {
status.innerHTML = `<span class="success">File selected: ${file.name}</span>`;
}
} else {
status.textContent = '';
}
}
</script>

Accessible modal and dialog implementations with proper focus management.

Basic Modal Dialog

<div id="modal-overlay"
class="modal-overlay"
hidden
onclick="handleOverlayClick(event)">

<div id="confirmation-modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
class="modal"
onkeydown="handleModalKeydown(event)">

<div class="modal-header">
<h2 id="modal-title">Confirm Deletion</h2>
<button type="button"
class="close-button"
aria-label="Close dialog"
onclick="closeModal()">
<span aria-hidden="true">×</span>
</button>
</div>

<div class="modal-body">
<p id="modal-description">
Are you sure you want to delete this item? This action cannot be undone.
</p>
</div>

<div class="modal-footer">
<button type="button"
class="btn btn-secondary"
onclick="closeModal()">
Cancel
</button>
<button type="button"
class="btn btn-danger"
onclick="confirmDelete()"
autofocus>
Delete
</button>
</div>
</div>
</div>

<script>
let previousActiveElement = null;
const focusManager = new FocusManager();

function openModal(modalId) {
// Store current focus
previousActiveElement = document.activeElement;

const overlay = document.getElementById('modal-overlay');
const modal = document.getElementById(modalId);

// Show modal
overlay.hidden = false;

// Trap focus within modal
focusManager.trapFocus(modal);

// Focus first focusable element or autofocus element
const autofocusElement = modal.querySelector('[autofocus]');
const firstFocusable = focusManager.getFocusableElements(modal)[0];

if (autofocusElement) {
autofocusElement.focus();
} else if (firstFocusable) {
firstFocusable.focus();
}

// Prevent body scroll
document.body.style.overflow = 'hidden';

// Announce to screen readers
setTimeout(() => {
liveRegions.announce('Dialog opened', 'status');
}, 100);
}

function closeModal() {
const overlay = document.getElementById('modal-overlay');

// Hide modal
overlay.hidden = true;

// Restore focus
if (previousActiveElement) {
previousActiveElement.focus();
previousActiveElement = null;
}

// Restore body scroll
document.body.style.overflow = '';

// Announce to screen readers
liveRegions.announce('Dialog closed', 'status');
}

function handleModalKeydown(event) {
if (event.key === 'Escape') {
closeModal();
}
}

function handleOverlayClick(event) {
if (event.target === event.currentTarget) {
closeModal();
}
}

function confirmDelete() {
// Perform deletion
liveRegions.announce('Item deleted successfully', 'status');
closeModal();
}
</script>

Alert Dialog

<div id="alert-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-title"
aria-describedby="alert-description"
class="modal alert-modal"
hidden>

<div class="modal-content">
<div class="alert-icon" aria-hidden="true">⚠️</div>
<h2 id="alert-title">System Error</h2>
<p id="alert-description">
An unexpected error occurred. Your work has been saved automatically.
</p>
<button type="button"
class="btn btn-primary"
onclick="closeAlertModal()"
autofocus>
OK
</button>
</div>
</div>

Form Dialog

<div id="form-modal"
role="dialog"
aria-modal="true"
aria-labelledby="form-modal-title"
class="modal form-modal"
hidden>

<form onsubmit="handleFormModalSubmit(event)" novalidate>
<div class="modal-header">
<h2 id="form-modal-title">Add New Contact</h2>
<button type="button"
aria-label="Close"
onclick="closeFormModal()">×</button>
</div>

<div class="modal-body">
<div class="form-group">
<label for="contact-name">Name *</label>
<input type="text"
id="contact-name"
name="name"
required
aria-describedby="name-error"
autofocus>
<div id="name-error"
role="alert"
aria-live="assertive"
class="error-message">
</div>
</div>

<div class="form-group">
<label for="contact-email">Email</label>
<input type="email"
id="contact-email"
name="email"
aria-describedby="email-error">
<div id="email-error"
role="alert"
aria-live="assertive"
class="error-message">
</div>
</div>
</div>

<div class="modal-footer">
<button type="button" onclick="closeFormModal()">Cancel</button>
<button type="submit">Add Contact</button>
</div>
</form>
</div>

Testing & Validation

Tools and techniques for testing accessibility implementation.

Automated Testing

// Basic accessibility testing utilities
class AccessibilityTester {
constructor() {
this.violations = [];
}

testFocusManagement() {
const focusableElements = document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

focusableElements.forEach(element => {
if (element.tabIndex === 0 && !this.isVisible(element)) {
this.violations.push({
type: 'focus',
element: element,
message: 'Focusable element is not visible'
});
}
});
}

testLabels() {
const inputs = document.querySelectorAll('input, select, textarea');

inputs.forEach(input => {
const hasLabel = this.hasLabel(input);
if (!hasLabel) {
this.violations.push({
type: 'label',
element: input,
message: 'Form control missing label'
});
}
});
}

testHeadingStructure() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
let previousLevel = 0;

headings.forEach((heading, index) => {
const level = parseInt(heading.tagName.charAt(1));

if (index === 0 && level !== 1) {
this.violations.push({
type: 'heading',
element: heading,
message: 'Page should start with h1'
});
}

if (level > previousLevel + 1) {
this.violations.push({
type: 'heading',
element: heading,
message: `Heading level jumps from h${previousLevel} to h${level}`
});
}

previousLevel = level;
});
}

testAriaLabels() {
const elementsWithAriaLabel = document.querySelectorAll('[aria-label]');
const elementsWithAriaLabelledby = document.querySelectorAll('[aria-labelledby]');

elementsWithAriaLabelledby.forEach(element => {
const ids = element.getAttribute('aria-labelledby').split(' ');
ids.forEach(id => {
if (!document.getElementById(id)) {
this.violations.push({
type: 'aria',
element: element,
message: `aria-labelledby references non-existent id: ${id}`
});
}
});
});
}

hasLabel(input) {
// Check for label element
if (input.labels && input.labels.length > 0) return true;

// Check for aria-label
if (input.getAttribute('aria-label')) return true;

// Check for aria-labelledby
if (input.getAttribute('aria-labelledby')) {
const ids = input.getAttribute('aria-labelledby').split(' ');
return ids.every(id => document.getElementById(id));
}

return false;
}

isVisible(element) {
const style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
}

runAllTests() {
this.violations = [];
this.testFocusManagement();
this.testLabels();
this.testHeadingStructure();
this.testAriaLabels();

return this.violations;
}

generateReport() {
const violations = this.runAllTests();

console.group('Accessibility Test Results');

if (violations.length === 0) {
console.log('✅ No violations found');
} else {
console.log(`❌ Found ${violations.length} violations:`);

violations.forEach((violation, index) => {
console.group(`${index + 1}. ${violation.type.toUpperCase()}`);
console.log('Message:', violation.message);
console.log('Element:', violation.element);
console.groupEnd();
});
}

console.groupEnd();

return violations;
}
}

// Usage
const tester = new AccessibilityTester();
tester.generateReport();

Manual Testing Checklist

## Accessibility Testing Checklist

### Keyboard Navigation
- [ ] All interactive elements are reachable via Tab key
- [ ] Tab order is logical and matches visual layout
- [ ] Focus indicators are visible and clear
- [ ] Escape key closes modals/menus appropriately
- [ ] Arrow keys work in menus, tabs, and other widgets
- [ ] Enter/Space activate buttons and links

### Screen Reader Testing
- [ ] Page has proper heading structure (h1, h2, h3, etc.)
- [ ] All images have appropriate alt text
- [ ] Form controls have labels
- [ ] Error messages are announced
- [ ] Dynamic content updates are announced
- [ ] Landmarks help with navigation

### Visual Testing
- [ ] Text has sufficient color contrast (4.5:1 for normal, 3:1 for large)
- [ ] Focus indicators are visible
- [ ] Text is readable when zoomed to 200%
- [ ] No information conveyed by color alone
- [ ] Content reflows properly on mobile devices

### ARIA Testing
- [ ] ARIA roles are used appropriately
- [ ] ARIA states update correctly (expanded, selected, etc.)
- [ ] aria-live regions announce changes
- [ ] No invalid ARIA attribute combinations

### Form Testing
- [ ] Required fields are clearly marked
- [ ] Validation errors are associated with fields
- [ ] Error messages are descriptive
- [ ] Success messages are announced
- [ ] Field groups use fieldset/legend appropriately

Testing Tools Integration

// Integration with popular testing tools
class AccessibilityTestSuite {
async runAxeTests() {
// Requires axe-core library
if (typeof axe !== 'undefined') {
try {
const results = await axe.run();
console.log('Axe test results:', results);
return results.violations;
} catch (error) {
console.error('Axe testing failed:', error);
return [];
}
} else {
console.warn('axe-core library not loaded');
return [];
}
}

simulateScreenReader() {
// Basic screen reader simulation
const elements = document.querySelectorAll('*');
const announcement = [];

elements.forEach(element => {
if (this.isVisible(element) && this.hasTextContent(element)) {
const role = element.getAttribute('role') || this.getImplicitRole(element);
const label = this.getAccessibleName(element);

if (label) {
announcement.push({
element: element,
role: role,
name: label,
states: this.getStates(element)
});
}
}
});

return announcement;
}

getImplicitRole(element) {
const roleMap = {
'button': 'button',
'a': element.href ? 'link' : null,
'input': this.getInputRole(element),
'h1': 'heading',
'h2': 'heading',
'h3': 'heading',
'h4': 'heading',
'h5': 'heading',
'h6': 'heading',
'nav': 'navigation',
'main': 'main',
'header': 'banner',
'footer': 'contentinfo',
'aside': 'complementary'
};

return roleMap[element.tagName.toLowerCase()] || null;
}

getInputRole(input) {
const typeRoleMap = {
'checkbox': 'checkbox',
'radio': 'radio',
'range': 'slider',
'text': 'textbox',
'email': 'textbox',
'password': 'textbox',
'search': 'searchbox'
};

return typeRoleMap[input.type] || 'textbox';
}

getAccessibleName(element) {
// Priority order for accessible name calculation

// 1. aria-label
if (element.hasAttribute('aria-label')) {
return element.getAttribute('aria-label');
}

// 2. aria-labelledby
if (element.hasAttribute('aria-labelledby')) {
const ids = element.getAttribute('aria-labelledby').split(' ');
const names = ids.map(id => {
const referencedElement = document.getElementById(id);
return referencedElement ? referencedElement.textContent.trim() : '';
}).filter(name => name);

if (names.length > 0) {
return names.join(' ');
}
}

// 3. Associated label
if (element.labels && element.labels.length > 0) {
return element.labels[0].textContent.trim();
}

// 4. alt attribute (for images)
if (element.hasAttribute('alt')) {
return element.getAttribute('alt');
}

// 5. title attribute
if (element.hasAttribute('title')) {
return element.getAttribute('title');
}

// 6. Text content (for certain elements)
if (['button', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(element.tagName.toLowerCase())) {
return element.textContent.trim();
}

return null;
}

getStates(element) {
const states = [];

// Common ARIA states
const ariaStates = [
'aria-expanded',
'aria-checked',
'aria-selected',
'aria-pressed',
'aria-disabled',
'aria-invalid',
'aria-hidden'
];

ariaStates.forEach(state => {
if (element.hasAttribute(state)) {
const value = element.getAttribute(state);
states.push(`${state}: ${value}`);
}
});

// HTML states
if (element.disabled) states.push('disabled');
if (element.required) states.push('required');
if (element.checked) states.push('checked');

return states;
}

isVisible(element) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);

return rect.width > 0 &&
rect.height > 0 &&
style.opacity !== '0' &&
style.visibility !== 'hidden' &&
style.display !== 'none';
}

hasTextContent(element) {
return element.textContent && element.textContent.trim().length > 0;
}
}

// Usage
const testSuite = new AccessibilityTestSuite();

// Run manual tests
const manualResults = new AccessibilityTester().runAllTests();

// Run axe tests (if available)
testSuite.runAxeTests().then(axeResults => {
console.log('Combined test results:', {
manual: manualResults,
axe: axeResults
});
});

// Simulate screen reader output
const srOutput = testSuite.simulateScreenReader();
console.log('Screen reader simulation:', srOutput);

Summary

This comprehensive guide covers:

  • ARIA Roles: Semantic meaning for assistive technologies
  • ARIA States & Properties: Dynamic states and relationships
  • Keyboard Navigation: Proper focus management and interaction patterns
  • Live Regions: Dynamic content announcements
  • Form Accessibility: Proper labeling, validation, and error handling
  • Modal Patterns: Focus trapping and proper dialog implementation
  • Testing: Automated and manual accessibility validation

Key Principles to Remember

  1. Semantic HTML First: Use native elements when possible before adding ARIA
  2. Progressive Enhancement: Build accessible foundations, then enhance
  3. Test with Real Users: Nothing replaces testing with actual assistive technology users
  4. Focus Management: Always know where focus is and where it should go
  5. Clear Communication: Provide clear, descriptive labels and instructions

Quick Reference

Essential ARIA attributes:

  • aria-label, aria-labelledby, aria-describedby for labeling
  • aria-expanded, aria-selected, aria-checked for states
  • aria-live, role="alert" for dynamic content
  • aria-hidden to hide decorative elements

Key keyboard patterns:

  • Tab/Shift+Tab for focus navigation
  • Enter/Space for activation
  • Arrow keys for widget navigation
  • Escape for dismissal/cancellation

Testing priorities:

  1. Keyboard-only navigation
  2. Screen reader compatibility
  3. Color contrast and visual clarity
  4. Error handling and feedback
  5. Mobile accessibility