ARIA Best Practices: Building Truly Accessible Websites
Introduction
Have you ever wondered why some websites feel effortless to navigate with a keyboard while others seem like impossible mazes? Or why screen readers seamlessly announce content changes on some sites but remain silent on others? The answer often lies in how well developers implement ARIA—Accessible Rich Internet Applications.
ARIA is a powerful set of attributes that bridges accessibility gaps when HTML alone falls short. But here’s the catch: research shows that websites using ARIA average 41% more accessibility errors than those without it. This isn’t because ARIA is inherently problematic—it’s because it’s often misused or overused.
In this guide, you’ll learn how to implement ARIA correctly, avoid common pitfalls that create barriers, and build web experiences that work beautifully for everyone—including the 15% of the world’s population living with disabilities.
Prerequisites
Before diving into ARIA implementation, you should have:
- Solid understanding of HTML5 semantic elements (
<nav>,<main>,<button>, etc.) - Basic JavaScript knowledge for dynamic content updates
- Familiarity with browser developer tools
- Access to a screen reader for testing (NVDA for Windows, VoiceOver for Mac, or TalkBack for Android)
- Understanding of WCAG 2.2 success criteria (recommended but not required)
The Golden Rule of ARIA: Use Native HTML First
The most important principle of ARIA is stated clearly in the W3C specification: “If you can use a native HTML element with the semantics and behavior you require already built in, instead of repurposing an element and adding an ARIA role, state or property to make it accessible, then do so.”
This principle exists because native HTML elements come with:
- Built-in keyboard accessibility
- Automatic roles and states
- Cross-browser compatibility
- Well-tested behavior patterns
- No additional code required
When Native HTML is Sufficient
<!-- WRONG: Unnecessary ARIA -->
<div role="button" tabindex="0" onclick="submit()"
onkeydown="handleKey(event)" aria-label="Submit form">
Submit
</div>
<!-- RIGHT: Native HTML button -->
<button type="submit">Submit</button>
The native <button> element automatically provides:
- Keyboard accessibility (Space and Enter keys)
- Focusability in the tab order
- Screen reader announcement as a button
- Standard visual focus indicators
When ARIA is Necessary
ARIA becomes essential when you need to:
- Create custom widgets not available in HTML (tree views, complex grids)
- Add semantic meaning to dynamically updated content
- Provide accessible names for elements lacking visible labels
- Communicate state changes to assistive technologies
- Build accessible single-page applications with dynamic routing
Understanding ARIA’s Core Components
ARIA consists of three main components that work together to enhance accessibility:
Roles
Roles define what an element is or does. Once set, roles don’t change.
<!-- Landmark roles for page structure -->
<div role="navigation" aria-label="Main menu">
<!-- Navigation content -->
</div>
<!-- Widget roles for interactive components -->
<div role="tablist">
<button role="tab" aria-selected="true">Profile</button>
<button role="tab" aria-selected="false">Settings</button>
</div>
<!-- Document roles for content structure -->
<div role="article">
<!-- Article content -->
</div>
States and Properties
States describe dynamic conditions that can change (aria-expanded, aria-selected), while properties define characteristics that rarely change (aria-label, aria-describedby).
<!-- States that change -->
<button aria-expanded="false" aria-controls="menu">
Menu
</button>
<!-- Properties that remain stable -->
<input type="text"
aria-label="Search products"
aria-describedby="search-help">
<span id="search-help">
Enter keywords to find products
</span>
Relationships
ARIA relationships connect elements to provide context and structure.
<h2 id="section-title">Account Settings</h2>
<div aria-labelledby="section-title">
<!-- Settings content -->
</div>
<!-- Group related controls -->
<div role="radiogroup" aria-labelledby="pizza-label">
<span id="pizza-label">Pizza size:</span>
<input type="radio" name="size" aria-label="Small">
<input type="radio" name="size" aria-label="Medium">
<input type="radio" name="size" aria-label="Large">
</div>
ARIA Implementation Patterns
Pattern 1: Accessible Labels
Providing accessible names is critical for interactive elements. Here are the main approaches:
<!-- Method 1: aria-label (when no visible label exists) -->
<button aria-label="Close dialog">
<svg><!-- X icon --></svg>
</button>
<!-- Method 2: aria-labelledby (references visible text) -->
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
<!-- Dialog content -->
</div>
<!-- Method 3: aria-describedby (adds additional context) -->
<input type="password"
id="new-password"
aria-describedby="password-requirements">
<div id="password-requirements">
Must be at least 8 characters with one number
</div>
<!-- CRITICAL: Visible label must be included in accessible name -->
<!-- WRONG: Violates WCAG 2.5.3 Label in Name -->
<button aria-label="Submit your application form">
Send
</button>
<!-- RIGHT: Accessible name includes visible text -->
<button aria-label="Send application">
Send
</button>
Key principle: When an element has visible text, the accessible name must include that text verbatim. This ensures speech input users can say “Click Send” and the button will activate.
Pattern 2: Live Regions for Dynamic Content
Live regions announce content updates without moving focus, essential for notifications, form validation, and real-time updates.
<!-- Setup: Empty live region on page load -->
<div id="status-message"
role="status"
aria-live="polite"
aria-atomic="true">
</div>
<script>
// Later, inject content to trigger announcement
function showNotification(message) {
const statusEl = document.getElementById('status-message');
// Clear first to ensure announcement on repeat messages
statusEl.textContent = '';
// Use setTimeout to ensure DOM update is detected
setTimeout(() => {
statusEl.textContent = message;
}, 100);
// Auto-clear after 5 seconds
setTimeout(() => {
statusEl.textContent = '';
}, 5100);
}
// Usage
document.querySelector('.add-to-cart').addEventListener('click', () => {
// Add item to cart...
showNotification('Item added to cart');
});
</script>
Live Region Politeness Levels
<!-- Polite: Most common, waits for user to pause -->
<div aria-live="polite">
Form saved successfully
</div>
<!-- Assertive: Interrupts immediately, use sparingly -->
<div aria-live="assertive">
Error: Payment failed. Please try again.
</div>
<!-- Off: No automatic announcements -->
<div aria-live="off">
<!-- Carousel content that updates frequently -->
</div>
Real-world example: Shopping cart notification
<style>
.cart-status {
position: fixed;
top: 20px;
right: 20px;
padding: 1rem;
background: #4CAF50;
color: white;
border-radius: 4px;
opacity: 0;
transition: opacity 0.3s;
}
.cart-status.show {
opacity: 1;
}
</style>
<!-- Visual notification -->
<div id="cart-notification" class="cart-status">
</div>
<!-- Accessible announcement -->
<div role="status" aria-live="polite" aria-atomic="true"
style="position: absolute; left: -10000px;">
<span id="cart-message"></span>
</div>
<script>
function addToCart(productName) {
// Add product to cart logic...
// Visual feedback
const notification = document.getElementById('cart-notification');
notification.textContent = `${productName} added to cart`;
notification.classList.add('show');
// Accessible announcement
document.getElementById('cart-message').textContent =
`${productName} added to cart`;
// Clear after 3 seconds
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
</script>
Pattern 3: Managing Focus in Composite Widgets
Complex widgets with multiple focusable elements need careful focus management.
Roving Tabindex Pattern
<ul role="tablist" aria-label="Account sections">
<li role="presentation">
<button role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
tabindex="0">
Profile
</button>
</li>
<li role="presentation">
<button role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
tabindex="-1">
Security
</button>
</li>
<li role="presentation">
<button role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
tabindex="-1">
Billing
</button>
</li>
</ul>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<!-- Profile content -->
</div>
<script>
class AccessibleTabs {
constructor(tablistEl) {
this.tablist = tablistEl;
this.tabs = Array.from(tablistEl.querySelectorAll('[role="tab"]'));
this.currentIndex = 0;
this.tabs.forEach((tab, index) => {
tab.addEventListener('click', () => this.selectTab(index));
tab.addEventListener('keydown', (e) => this.handleKeydown(e, index));
});
}
selectTab(index) {
// Deselect all tabs
this.tabs.forEach(tab => {
tab.setAttribute('aria-selected', 'false');
tab.setAttribute('tabindex', '-1');
const panel = document.getElementById(tab.getAttribute('aria-controls'));
panel.hidden = true;
});
// Select target tab
const selectedTab = this.tabs[index];
selectedTab.setAttribute('aria-selected', 'true');
selectedTab.setAttribute('tabindex', '0');
selectedTab.focus();
// Show associated panel
const panel = document.getElementById(
selectedTab.getAttribute('aria-controls')
);
panel.hidden = false;
this.currentIndex = index;
}
handleKeydown(event, index) {
let newIndex = index;
switch(event.key) {
case 'ArrowLeft':
newIndex = index > 0 ? index - 1 : this.tabs.length - 1;
break;
case 'ArrowRight':
newIndex = index < this.tabs.length - 1 ? index + 1 : 0;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = this.tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
this.selectTab(newIndex);
}
}
// Initialize
document.querySelectorAll('[role="tablist"]').forEach(tablist => {
new AccessibleTabs(tablist);
});
</script>
Pattern 4: Modal Dialogs with Focus Trapping
Modal dialogs require careful focus management to prevent keyboard users from tabbing out.
<button id="open-dialog">Delete Account</button>
<div id="dialog-backdrop" hidden>
<div role="dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
aria-modal="true">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-desc">
This action cannot be undone. Are you sure?
</p>
<button id="cancel-btn">Cancel</button>
<button id="confirm-btn">Delete Account</button>
</div>
</div>
<script>
class AccessibleDialog {
constructor(dialogId) {
this.dialog = document.getElementById(dialogId);
this.backdrop = this.dialog.closest('#dialog-backdrop');
this.focusableElements = this.dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
this.firstFocusable = this.focusableElements[0];
this.lastFocusable = this.focusableElements[this.focusableElements.length - 1];
this.previousFocus = null;
}
open() {
// Store element that triggered dialog
this.previousFocus = document.activeElement;
// Show dialog
this.backdrop.hidden = false;
// Prevent body scroll
document.body.style.overflow = 'hidden';
// Move focus to dialog
this.firstFocusable.focus();
// Trap focus
this.dialog.addEventListener('keydown', this.trapFocus.bind(this));
}
close() {
// Hide dialog
this.backdrop.hidden = true;
// Restore body scroll
document.body.style.overflow = '';
// Return focus to triggering element
if (this.previousFocus) {
this.previousFocus.focus();
}
// Remove focus trap
this.dialog.removeEventListener('keydown', this.trapFocus);
}
trapFocus(event) {
// Handle Escape key
if (event.key === 'Escape') {
this.close();
return;
}
// Handle Tab key
if (event.key === 'Tab') {
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === this.firstFocusable) {
event.preventDefault();
this.lastFocusable.focus();
}
} else {
// Tab
if (document.activeElement === this.lastFocusable) {
event.preventDefault();
this.firstFocusable.focus();
}
}
}
}
}
// Usage
const dialog = new AccessibleDialog('dialog-backdrop');
document.getElementById('open-dialog').addEventListener('click', () => {
dialog.open();
});
document.getElementById('cancel-btn').addEventListener('click', () => {
dialog.close();
});
document.getElementById('confirm-btn').addEventListener('click', () => {
// Handle confirmation...
dialog.close();
});
</script>
Common Pitfalls and How to Avoid Them
Pitfall 1: Syntax Errors
ARIA is case-sensitive and unforgiving. A single typo can break accessibility.
<!-- WRONG: Common typos -->
<div aria-labeledby="title"> <!-- Missing 'l' -->
<div aria-role="button"> <!-- No 'aria-role' attribute -->
<div role="Button"> <!-- Must be lowercase -->
<!-- RIGHT: Correct syntax -->
<div aria-labelledby="title">
<div role="button">
<div role="button">
Solution: Use linting tools like eslint-plugin-jsx-a11y or axe DevTools to catch errors during development.
Pitfall 2: Overwriting Visible Labels
When aria-label is used, it completely replaces any visible text, creating a mismatch between what sighted users see and what screen readers announce.
<!-- WRONG: Mismatch between visible and accessible name -->
<button aria-label="Submit application form">
Send
</button>
<!-- Screen reader says "Submit application form"
Visual users see "Send"
Speech input users can't say "Click Send" -->
<!-- RIGHT: Accessible name matches visible text -->
<button>Send Application</button>
<!-- Or if you need additional context: -->
<button aria-label="Send application form">
Send
</button>
Solution: Always include visible text in the accessible name. Use aria-describedby for additional context instead of aria-label when visible text exists.
Pitfall 3: Missing Keyboard Support
Adding ARIA roles doesn’t automatically provide keyboard functionality.
<!-- WRONG: No keyboard support -->
<div role="button" onclick="submit()">
Submit
</div>
<!-- Can't be focused or activated with keyboard -->
<!-- RIGHT: Full keyboard support -->
<div role="button"
tabindex="0"
onclick="submit()"
onkeydown="if(event.key === 'Enter' || event.key === ' ') submit()">
Submit
</div>
<!-- BEST: Use native HTML -->
<button onclick="submit()">Submit</button>
Solution: Always implement expected keyboard interactions for custom widgets, or better yet, use native HTML elements.
Pitfall 4: Broken ID References
ARIA attributes like aria-labelledby and aria-describedby rely on element IDs. If the referenced ID doesn’t exist or is duplicated, the association breaks.
<!-- WRONG: Referenced ID doesn't exist -->
<input aria-describedby="help-text">
<!-- No element with id="help-text" -->
<!-- WRONG: Duplicate IDs -->
<div id="error-msg">Field required</div>
<div id="error-msg">Invalid format</div>
<input aria-describedby="error-msg">
<!-- RIGHT: Valid, unique ID reference -->
<input id="email" aria-describedby="email-help">
<span id="email-help">
We'll never share your email
</span>
Solution: Validate ID references during development and use automated testing to catch duplicates.
Pitfall 5: Incorrect aria-expanded State
Copying and pasting code can lead to aria-expanded being set to the wrong value.
<!-- WRONG: Accordion closed but aria-expanded="true" -->
<button aria-expanded="true" aria-controls="content">
Show Details
</button>
<div id="content" hidden>
<!-- Content -->
</div>
<!-- RIGHT: State matches visual state -->
<button aria-expanded="false" aria-controls="content">
Show Details
</button>
<div id="content" hidden>
<!-- Content -->
</div>
<script>
const button = document.querySelector('button');
const content = document.getElementById('content');
button.addEventListener('click', () => {
const isExpanded = button.getAttribute('aria-expanded') === 'true';
// Toggle state
button.setAttribute('aria-expanded', !isExpanded);
content.hidden = isExpanded;
// Update button text
button.textContent = isExpanded ? 'Show Details' : 'Hide Details';
});
</script>
Pitfall 6: Hiding Focusable Elements with aria-hidden
Using aria-hidden=“true” on focusable elements creates a confusing experience where keyboard users can focus on elements that screen readers can’t announce.
<!-- WRONG: Focusable but hidden from screen readers -->
<a href="/products" aria-hidden="true">
View Products
</a>
<!-- Keyboard user tabs to link and hears nothing -->
<!-- RIGHT: If hidden from screen readers, also hide from keyboard -->
<a href="/products" aria-hidden="true" tabindex="-1">
View Products
</a>
<!-- BETTER: Only hide decorative elements -->
<button>
<svg aria-hidden="true"><!-- Decorative icon --></svg>
Save Changes
</button>
Pitfall 7: Missing Parent-Child Role Requirements
Some ARIA roles require specific parent-child relationships. Breaking these requirements can cause assistive technologies to ignore the elements.
<!-- WRONG: listitem without list -->
<div role="listitem">Item 1</div>
<div role="listitem">Item 2</div>
<!-- RIGHT: Proper hierarchy -->
<div role="list">
<div role="listitem">Item 1</div>
<div role="listitem">Item 2</div>
</div>
<!-- WRONG: option without listbox -->
<div role="option">Choice A</div>
<!-- RIGHT: Complete structure -->
<div role="listbox" aria-label="Choose one">
<div role="option" aria-selected="true">Choice A</div>
<div role="option" aria-selected="false">Choice B</div>
</div>
Testing Your ARIA Implementation
Automated testing catches about 30-40% of accessibility issues. Manual testing is essential.
Automated Testing Tools
// Install axe-core for automated testing
// npm install --save-dev axe-core
// Example test with Jest and axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('page should have no accessibility violations', async () => {
const html = `
<button aria-label="Close">X</button>
`;
const results = await axe(html);
expect(results).toHaveNoViolations();
});
Manual Testing Checklist
Keyboard Testing:
- Tab through all interactive elements
- Verify focus order matches visual order
- Check that focus is always visible
- Test custom keyboard shortcuts
- Ensure focus isn’t trapped unintentionally
Screen Reader Testing:
- Windows: NVDA (free) or JAWS
- Mac: VoiceOver (built-in)
- Mobile: TalkBack (Android) or VoiceOver (iOS)
Key things to verify:
- All interactive elements are announced with their role
- Labels and descriptions are spoken correctly
- State changes are announced (expanded/collapsed, selected, etc.)
- Live regions announce updates appropriately
- Navigation landmarks are recognized
Screen Reader Testing Example
# NVDA (Windows)
# Key commands:
# NVDA + Down Arrow: Read next line
# NVDA + Tab: Announce focused element
# NVDA + F7: List all links
# NVDA + D: List all regions/landmarks
# VoiceOver (Mac)
# VO = Control + Option
# VO + Right Arrow: Move to next item
# VO + U: Open rotor (list headings, links, landmarks)
# VO + Space: Activate element
Conclusion
ARIA is a powerful tool that, when used correctly, makes modern web applications accessible to everyone. Remember these key takeaways:
- Prioritize semantic HTML: Native elements should always be your first choice
- Follow the “No ARIA is better than bad ARIA” principle: Only add ARIA when necessary
- Test with real assistive technologies: Automated tools catch only a fraction of issues
- Maintain consistency: Follow established ARIA design patterns from the W3C APG
- Keep learning: ARIA specifications evolve, and staying current is essential
By implementing these best practices and avoiding common pitfalls, you’ll create web experiences that are truly accessible—not just compliant on paper, but genuinely usable by people with diverse abilities and assistive technologies.
Next Steps
- Explore the W3C ARIA Authoring Practices Guide for comprehensive widget patterns
- Set up automated accessibility testing in your CI/CD pipeline
- Practice with screen readers weekly to maintain familiarity
- Join the accessibility community on platforms like the A11y Slack
- Consider getting IAAP certification (WAS or CPACC) to deepen your expertise
References:
- WAI-ARIA Overview - MDN - Comprehensive ARIA documentation and examples
- ARIA Authoring Practices Guide - W3C - Official design patterns and implementation guidance
- Common ARIA Problems in Accessibility Audits - Bogdan on A11y - Real-world issues found in audits
- ARIA Live Regions - MDN - Dynamic content announcement techniques
- WebAIM Screen Reader Survey - Usage statistics showing ARIA has 41% more errors when misused
- Keyboard Accessibility - WebAIM - Essential keyboard navigation patterns