
Introduction
In 2025, building websites without considering accessibility isn’t just a missed opportunity—it’s a liability. With over 1 billion people worldwide living with disabilities and an increasing number of legal requirements like the ADA, WCAG 2.2, and the European Accessibility Act, accessibility has shifted from a “nice-to-have” to a “must-have” in web development. Yet, despite this importance, many development teams still treat accessibility as an afterthought, a checkbox to tick after the main development is complete.
This approach not only risks legal complications but also results in subpar experiences for a significant portion of users. The good news is that modern frameworks, design patterns, and tools have made it easier than ever to build accessibility directly into your development workflow. In this article, we’ll explore how to adopt an accessibility-first mindset, implementing the latest ARIA patterns and accessibility best practices from the ground up. You’ll learn practical techniques for creating truly inclusive web applications that work for everyone, regardless of ability or how they access the web.
The evolution of web accessibility
Web accessibility has evolved significantly over the past few years. WCAG 2.2, released in late 2023, introduced new success criteria addressing the needs of users with cognitive disabilities, low vision, and mobile accessibility requirements. The proposed WCAG 3.0 (expected in late 2025) is set to bring even more comprehensive guidelines focusing on testable outcomes rather than technical specifications.
The most significant shift, however, has been in how we approach accessibility implementation. We’ve moved from a “bolt-on” approach—where accessibility features were added after development—to an “accessibility-first” methodology where accessible patterns are the foundation of component design.
For example, consider these two approaches to building a modal dialog:
Traditional approach (2020):
- Build modal dialog with HTML, CSS, and JavaScript
- Add ARIA attributes afterward
- Test with screen readers
- Fix issues that arise
Accessibility-first approach (2025):
- Select an accessible dialog pattern from the start
- Implement with semantic HTML and built-in accessibility features
- Test throughout development
- Focus on enhancing the experience for all users
This shift doesn’t just make sites more accessible—it often improves the experience for all users, perfectly illustrating the “curb cut effect” where accommodations designed for people with disabilities benefit everyone.
Modern ARIA patterns and best practices
ARIA (Accessible Rich Internet Applications) has become more sophisticated and easier to implement correctly. Here are the key ARIA patterns you should be using in 2025:
1. Accessible navigation menus
Modern navigation menus need to work for keyboard, screen reader, and touch users. The newest patterns leverage both semantic HTML and minimal ARIA:
<!-- Accessible Navigation Menu -->
<nav aria-label="Main">
<button id="menu-toggle" aria-expanded="false" aria-controls="main-menu">
Menu
<span class="sr-only">toggle</span>
</button>
<ul id="main-menu" role="menu" hidden>
<li role="none">
<a role="menuitem" href="/">Home</a>
</li>
<li role="none">
<button role="menuitem" aria-expanded="false" aria-controls="services-menu">
Services
</button>
<ul id="services-menu" role="menu" hidden>
<li role="none">
<a role="menuitem" href="/services/web-design">Web Design</a>
</li>
<li role="none">
<a role="menuitem" href="/services/development">Development</a>
</li>
</ul>
</li>
<li role="none">
<a role="menuitem" href="/contact">Contact</a>
</li>
</ul>
</nav>
The critical aspects here are:
- Using proper semantics (
<nav>
,<ul>
,<li>
) - Applying appropriate ARIA roles
- Managing focus states with JavaScript
- Including state indicators (via
aria-expanded
) - Providing clear screen reader instructions
2. Modern form validation and error handling
Forms remain a significant accessibility challenge. The latest patterns use a combination of built-in validation, ARIA live regions, and clear error messaging:
<form novalidate>
<div class="form-field">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
aria-describedby="email-hint email-error"
required
/>
<div id="email-hint" class="hint">
We'll never share your email.
</div>
<div id="email-error" class="error" role="alert" aria-live="assertive"></div>
</div>
<button type="submit">Submit</button>
</form>
<script>
const form = document.querySelector('form');
const emailInput = document.getElementById('email');
const emailError = document.getElementById('email-error');
form.addEventListener('submit', (event) => {
if (!emailInput.validity.valid) {
event.preventDefault();
if (emailInput.validity.valueMissing) {
emailError.textContent = 'Please enter your email address.';
} else if (emailInput.validity.typeMismatch) {
emailError.textContent = 'Please enter a valid email address.';
}
emailInput.setAttribute('aria-invalid', 'true');
emailInput.focus();
}
});
// Clear errors when user starts typing again
emailInput.addEventListener('input', () => {
if (emailInput.hasAttribute('aria-invalid')) {
emailError.textContent = '';
emailInput.removeAttribute('aria-invalid');
}
});
</script>
The key improvements here include:
- Using
aria-describedby
to associate inputs with both hints and errors - Implementing
role="alert"
andaria-live="assertive"
to announce errors - Setting focus on invalid inputs
- Using
aria-invalid
to indicate validation state - Providing clear, specific error messages
3. Accessible modal dialogs
Modal dialogs are notorious for accessibility issues. The modern approach uses the native <dialog>
element with some ARIA enhancements:
<button id="open-dialog">Open Settings</button>
<dialog id="settings-dialog" aria-labelledby="dialog-title">
<div class="dialog-content">
<h2 id="dialog-title">Application Settings</h2>
<form method="dialog">
<fieldset>
<legend>Theme Preferences</legend>
<div class="form-control">
<input type="radio" id="theme-light" name="theme" value="light" checked>
<label for="theme-light">Light</label>
</div>
<div class="form-control">
<input type="radio" id="theme-dark" name="theme" value="dark">
<label for="theme-dark">Dark</label>
</div>
<div class="form-control">
<input type="radio" id="theme-system" name="theme" value="system">
<label for="theme-system">System</label>
</div>
</fieldset>
<div class="dialog-actions">
<button type="button" id="cancel-button">Cancel</button>
<button type="submit" id="save-button">Save Changes</button>
</div>
</form>
</div>
</dialog>
<script>
const openButton = document.getElementById('open-dialog');
const dialog = document.getElementById('settings-dialog');
const cancelButton = document.getElementById('cancel-button');
openButton.addEventListener('click', () => {
dialog.showModal();
// Set focus to first interactive element
document.getElementById('theme-light').focus();
});
cancelButton.addEventListener('click', () => {
dialog.close();
// Return focus to the element that opened the dialog
openButton.focus();
});
// Close dialog when clicking on backdrop (the dialog's ::backdrop)
dialog.addEventListener('click', (event) => {
if (event.target === dialog) {
dialog.close();
openButton.focus();
}
});
// Handle escape key (native to <dialog>)
dialog.addEventListener('close', () => {
// When dialog closes naturally, return focus
openButton.focus();
});
</script>
The key accessibility features include:
- Using the native
<dialog>
element which handles much of the accessibility automatically - Proper labeling with
aria-labelledby
- Focus management (setting initial focus and returning focus after closing)
- Closing on backdrop click
- Supporting the ESC key (built into
<dialog>
) - Proper focus trapping within the modal (also handled by native
<dialog>
)
4. Accessible tabs component
Tabs are complex components that require careful implementation for accessibility:
<div class="tabs-component">
<div role="tablist" aria-label="Product Information">
<button
role="tab"
id="tab-features"
aria-selected="true"
aria-controls="panel-features"
>
Features
</button>
<button
role="tab"
id="tab-specs"
aria-selected="false"
aria-controls="panel-specs"
tabindex="-1"
>
Specifications
</button>
<button
role="tab"
id="tab-reviews"
aria-selected="false"
aria-controls="panel-reviews"
tabindex="-1"
>
Reviews
</button>
</div>
<div
role="tabpanel"
id="panel-features"
aria-labelledby="tab-features"
>
<h3>Product Features</h3>
<ul>
<li>Feature 1: Description</li>
<li>Feature 2: Description</li>
</ul>
</div>
<div
role="tabpanel"
id="panel-specs"
aria-labelledby="tab-specs"
hidden
>
<h3>Product Specifications</h3>
<table>
<!-- Table content -->
</table>
</div>
<div
role="tabpanel"
id="panel-reviews"
aria-labelledby="tab-reviews"
hidden
>
<h3>Customer Reviews</h3>
<!-- Reviews content -->
</div>
</div>
<script>
const tabs = document.querySelectorAll('[role="tab"]');
const tabPanels = document.querySelectorAll('[role="tabpanel"]');
// Add click handlers to tabs
tabs.forEach(tab => {
tab.addEventListener('click', changeTabs);
});
// Enable keyboard navigation for tabs
tabs.forEach(tab => {
tab.addEventListener('keydown', e => {
let targetTab;
// Get the index of the current tab
const index = Array.from(tabs).indexOf(e.currentTarget);
// Define keys and directions
switch (e.key) {
case 'ArrowRight':
targetTab = tabs[(index + 1) % tabs.length];
break;
case 'ArrowLeft':
targetTab = tabs[(index - 1 + tabs.length) % tabs.length];
break;
case 'Home':
targetTab = tabs[0];
break;
case 'End':
targetTab = tabs[tabs.length - 1];
break;
default:
return;
}
// Activate the target tab
if (targetTab) {
e.preventDefault();
targetTab.click();
targetTab.focus();
}
});
});
function changeTabs(e) {
const targetTab = e.currentTarget;
const targetPanel = document.getElementById(
targetTab.getAttribute('aria-controls')
);
// Hide all panels
tabPanels.forEach(panel => {
panel.hidden = true;
});
// Mark all tabs as unselected and add tabindex=-1
tabs.forEach(tab => {
tab.setAttribute('aria-selected', 'false');
tab.tabIndex = -1;
});
// Show the target panel
targetPanel.hidden = false;
// Mark the clicked tab as selected and set tabindex=0
targetTab.setAttribute('aria-selected', 'true');
targetTab.tabIndex = 0;
}
</script>
The key accessibility features are:
- Proper ARIA roles (
tablist
,tab
,tabpanel
) - Correct ARIA attributes (
aria-selected
,aria-controls
,aria-labelledby
) - Keyboard navigation for tabs
- Proper focus management
- Control of
tabindex
to manage keyboard focus
5. Accessible dark mode toggle
With dark mode now standard, here’s how to create an accessible theme switcher:
<button
id="theme-toggle"
aria-pressed="false"
aria-label="Enable dark mode"
>
<span class="icon-light">☀️</span>
<span class="icon-dark">🌙</span>
<span class="sr-only">Toggle theme</span>
</button>
<script>
const themeToggle = document.getElementById('theme-toggle');
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
// Initialize theme based on user preference or stored setting
function initializeTheme() {
// Check localStorage first
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'dark') {
enableDarkMode();
} else if (storedTheme === 'light') {
enableLightMode();
} else {
// If no stored preference, use system preference
if (prefersDarkScheme.matches) {
enableDarkMode();
} else {
enableLightMode();
}
}
}
function enableDarkMode() {
document.documentElement.classList.add('dark-theme');
themeToggle.setAttribute('aria-pressed', 'true');
themeToggle.setAttribute('aria-label', 'Disable dark mode');
localStorage.setItem('theme', 'dark');
}
function enableLightMode() {
document.documentElement.classList.remove('dark-theme');
themeToggle.setAttribute('aria-pressed', 'false');
themeToggle.setAttribute('aria-label', 'Enable dark mode');
localStorage.setItem('theme', 'light');
}
themeToggle.addEventListener('click', () => {
// Check if dark mode is currently enabled
if (document.documentElement.classList.contains('dark-theme')) {
enableLightMode();
} else {
enableDarkMode();
}
});
// Listen for OS theme changes
prefersDarkScheme.addEventListener('change', (e) => {
// Only auto-switch if the user hasn't manually set a preference
if (!localStorage.getItem('theme')) {
if (e.matches) {
enableDarkMode();
} else {
enableLightMode();
}
}
});
// Initialize theme on page load
initializeTheme();
</script>
The accessibility features include:
- Using
aria-pressed
to indicate the current state - Dynamic
aria-label
to describe the action - Screen reader text for clarity
- Respecting user preferences via
prefers-color-scheme
- Visual indicators with both icons and colors
6. Accessible autocomplete component
Autocomplete components present unique accessibility challenges:
<div class="autocomplete">
<label for="country">Choose a country:</label>
<div class="combobox-wrapper">
<input
type="text"
id="country"
role="combobox"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="country-listbox"
aria-activedescendant=""
autocomplete="off"
/>
<button
aria-label="Show countries"
tabindex="-1"
class="combobox-arrow"
>
▼
</button>
</div>
<ul
id="country-listbox"
role="listbox"
aria-label="Countries"
class="suggestions-list"
hidden
>
<!-- Dynamically populated options will go here -->
</ul>
<div
id="country-status"
role="status"
class="sr-only"
aria-live="polite"
></div>
</div>
<script>
const countries = ["Afghanistan", "Albania", /* ...full list... */, "Zimbabwe"];
const input = document.getElementById('country');
const listbox = document.getElementById('country-listbox');
const status = document.getElementById('country-status');
const arrow = document.querySelector('.combobox-arrow');
// Show suggestions based on input
input.addEventListener('input', updateSuggestions);
// Show all options when arrow is clicked
arrow.addEventListener('click', () => {
if (input.getAttribute('aria-expanded') === 'false') {
showAllOptions();
} else {
hideSuggestions();
}
});
// Handle keyboard interaction for selecting options
input.addEventListener('keydown', (e) => {
const suggestions = listbox.querySelectorAll('[role="option"]');
if (suggestions.length === 0) return;
// Find current selected option
const currentOption = listbox.querySelector('[aria-selected="true"]');
const currentIndex = currentOption
? Array.from(suggestions).indexOf(currentOption)
: -1;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (input.getAttribute('aria-expanded') === 'false') {
updateSuggestions();
} else {
const nextIndex = currentIndex < suggestions.length - 1 ? currentIndex + 1 : 0;
selectOption(suggestions[nextIndex]);
}
break;
case 'ArrowUp':
e.preventDefault();
if (input.getAttribute('aria-expanded') === 'true') {
const prevIndex = currentIndex > 0 ? currentIndex - 1 : suggestions.length - 1;
selectOption(suggestions[prevIndex]);
}
break;
case 'Enter':
if (currentOption && input.getAttribute('aria-expanded') === 'true') {
e.preventDefault();
chooseOption(currentOption);
}
break;
case 'Escape':
if (input.getAttribute('aria-expanded') === 'true') {
e.preventDefault();
hideSuggestions();
}
break;
default:
break;
}
});
// Function to select an option (highlight but don't choose)
function selectOption(option) {
// Remove selection from all options
listbox.querySelectorAll('[role="option"]').forEach(opt => {
opt.setAttribute('aria-selected', 'false');
});
// Select the current option
option.setAttribute('aria-selected', 'true');
// Update the activedescendant to point to this option
input.setAttribute('aria-activedescendant', option.id);
// Ensure the option is visible in the listbox (scroll if needed)
option.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Function to choose an option (select and apply)
function chooseOption(option) {
input.value = option.textContent;
input.setAttribute('aria-activedescendant', '');
hideSuggestions();
// Announce selection to screen reader
status.textContent = `${option.textContent} selected.`;
}
// Update suggestions based on input
function updateSuggestions() {
const value = input.value.toLowerCase();
// Filter countries based on input
const matchingCountries = countries.filter(country =>
country.toLowerCase().startsWith(value)
);
// Clear current options
listbox.innerHTML = '';
if (matchingCountries.length > 0) {
// Create and append new options
matchingCountries.forEach((country, index) => {
const option = document.createElement('li');
option.textContent = country;
option.id = `country-option-${index}`;
option.role = 'option';
option.tabIndex = -1;
option.setAttribute('aria-selected', 'false');
option.addEventListener('click', () => {
chooseOption(option);
});
listbox.appendChild(option);
});
// Show listbox and update ARIA attributes
listbox.hidden = false;
input.setAttribute('aria-expanded', 'true');
// Announce count to screen readers
status.textContent = `${matchingCountries.length} suggestions available.`;
// Select first option
selectOption(listbox.querySelector('[role="option"]'));
} else {
hideSuggestions();
if (value) {
status.textContent = 'No suggestions available.';
}
}
}
// Show all available options
function showAllOptions() {
// Clear current options
listbox.innerHTML = '';
// Create and append all options
countries.forEach((country, index) => {
const option = document.createElement('li');
option.textContent = country;
option.id = `country-option-${index}`;
option.role = 'option';
option.tabIndex = -1;
option.setAttribute('aria-selected', 'false');
option.addEventListener('click', () => {
chooseOption(option);
});
listbox.appendChild(option);
});
// Show listbox and update ARIA attributes
listbox.hidden = false;
input.setAttribute('aria-expanded', 'true');
// Announce to screen readers
status.textContent = `${countries.length} suggestions available.`;
}
// Hide suggestions
function hideSuggestions() {
listbox.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.setAttribute('aria-activedescendant', '');
}
// Close suggestions when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete')) {
hideSuggestions();
}
});
</script>
The key accessibility features are:
- Complex ARIA roles and attributes:
combobox
,listbox
,option
- Proper keyboard navigation
- Managing focus and active descendant
- Live regions for status updates
- Clear visual and programmatic indications of state
7. Accessible toast notifications
Toast notifications need special attention for screen reader users:
<div id="toast-container" role="status" aria-live="polite"></div>
<script>
function showToast(message, type = 'info', duration = 5000) {
const toastContainer = document.getElementById('toast-container');
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Add appropriate icon based on type
const icon = getIconForType(type);
// Create toast content
toast.innerHTML = `
<div class="toast-icon">${icon}</div>
<div class="toast-message">${message}</div>
<button class="toast-close" aria-label="Dismiss message">×</button>
`;
// Add toast to container
toastContainer.appendChild(toast);
// Setup dismiss button
const closeButton = toast.querySelector('.toast-close');
closeButton.addEventListener('click', () => {
dismissToast(toast);
});
// Auto-dismiss after duration
if (duration > 0) {
setTimeout(() => {
if (toastContainer.contains(toast)) {
dismissToast(toast);
}
}, duration);
}
// Handle escape key to dismiss all toasts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && toastContainer.contains(toast)) {
dismissToast(toast);
}
});
// Animate toast in
setTimeout(() => {
toast.classList.add('show');
}, 10);
}
function dismissToast(toast) {
// Animate toast out
toast.classList.remove('show');
// Remove from DOM after animation
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300); // Match CSS transition duration
}
function getIconForType(type) {
switch (type) {
case 'success': return '✓';
case 'error': return '✕';
case 'warning': return '⚠';
case 'info':
default: return 'ℹ';
}
}
// Example usage
document.getElementById('show-toast-btn').addEventListener('click', () => {
showToast('Your profile has been updated successfully!', 'success');
});
</script>
The accessibility features include:
- Using
role="status"
andaria-live="polite"
to announce non-critical messages - Providing dismiss functionality via a button
- Supporting keyboard dismissal with the ESC key
- Using both color and icons to convey message types
- Limiting duration based on message length (longer messages need more time)
Integrating accessibility testing into your workflow
To truly adopt an accessibility-first approach, testing must be integrated throughout the development process.
Component-level testing
Test each component individually before integration:
- Keyboard Navigation: Can you access all interactive elements using only the keyboard?
- Screen Reader Testing: Do all elements provide appropriate information to screen readers?
- High-Contrast Testing: Does the component remain usable in high-contrast mode?
- Zoom Testing: Does the component maintain usability at 200% zoom?
Automated testing tools
While automated tools can’t catch everything, they’re valuable for early feedback:
- axe DevTools: Browser extension for quick accessibility audits
- Lighthouse: Built into Chrome DevTools
- WAVE Evaluation Tool: Provides visual indicators of accessibility issues
- Jest-axe: For automated accessibility testing in your test suite
Here’s an example of integrating axe testing with Jest:
// button.test.js
import React from 'react';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from './Button';
expect.extend(toHaveNoViolations);
describe('Button component', () => {
it('should not have any accessibility violations', async () => {
const { container } = render(<Button>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should not have any accessibility violations when disabled', async () => {
const { container } = render(<Button disabled>Click me</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
Manual accessibility testing
Human testing remains essential. Implement these practices:
- Keyboard-only testing: Navigate your entire application without a mouse
- Screen reader testing: Test with at least one screen reader (NVDA, JAWS, or VoiceOver)
- User testing: Whenever possible, include people with disabilities in your testing process
- Cognitive accessibility: Test for clear instructions, error prevention, and simple language
Try it yourself: implementing an accessibility audit
Ready to evaluate your existing web applications? Follow this step-by-step process:
- Run an automated scan
- Use axe DevTools to scan key pages
- Document all issues found
- Prioritize by severity
- Conduct a manual audit
- Test keyboard navigation throughout the site
- Verify all interactive elements have appropriate focus states
- Check that all images have alt text
- Ensure forms have proper labels and error handling
- Test with a screen reader
- Create an accessibility remediation plan
- List all issues by priority
- Assign responsibility
- Set deadlines
- Track progress
- Implement accessibility training
- Train all team members on basic accessibility principles
- Provide developers with specific coding patterns
- Offer designers guidance on accessible design
- Give content creators guidelines for accessible content
- Build accessibility into your CI/CD pipeline
- Add automated accessibility testing to your build process
- Block deployments for critical accessibility issues
- Track accessibility metrics over time
Conclusion
Building accessible web applications isn’t just about compliance—it’s about creating experiences that work for everyone. By adopting an accessibility-first approach, implementing modern ARIA patterns, and integrating testing throughout your development workflow, you can create web applications that are not only more inclusive but often more usable for all users.
Remember that accessibility is a journey, not a destination. Standards evolve, user needs change, and new technologies emerge. The key is establishing a mindset and processes that prioritize accessibility from the start, rather than treating it as an afterthought.
If you’re looking to improve the accessibility of your existing applications or ensure new projects meet the highest accessibility standards, consider working with specialists who can provide targeted guidance. Our team offers comprehensive accessibility audits, remediation assistance, and developer training to help you build truly inclusive web experiences.