Build accessible web applications that work for everyone. Covers WCAG compliance, semantic HTML, ARIA patterns, keyboard navigation, screen reader testing, and making accessibility a first-class engineering concern.
Accessibility Engineering is the disciplined practice of building user interfaces that are usable by people of all abilities—especially those with visual, auditory, motor, and cognitive impairments. It matters most when the interface must serve diverse users across devices, input methods, and network conditions, and when failure is visible: a screen reader missing a critical form label, a keyboard user stuck in a modal, or a low-vision user unable to perceive interactive elements. This page covers how to engineer for accessibility at scale, not just how to add ARIA labels to a few components.
Keyboard navigation is foundational. It enables users with motor impairments, screen readers, and those who prefer input over mouse. The goal is full, predictable, and discoverable navigation using only the keyboard.
The primary mechanism is the tabindex attribute. Elements with tabindex="0" receive focus in document order; tabindex="1" or higher can be reordered. For complex components, use tabindex="-1" to make an element focusable but not in the tab order.
<nav aria-label="Main navigation">
<ul>
<li><a href="/home" tabindex="0">Home</a></li>
<li><a href="/about" tabindex="0">About</a></li>
<li><a href="/contact" tabindex="0">Contact</a></li>
</ul>
</nav>
<div id="search-modal" role="dialog" aria-modal="true" tabindex="-1">
<button id="close-modal" aria-label="Close search modal">✕</button>
<input type="search" placeholder="Search..." aria-label="Search input" />
<button type="submit">Search</button>
</div>
Focus is indicated by :focus styles. Use outline and border to provide clear visual feedback. A common failure is relying solely on :focus without :focus-visible, which leads to redundant outlines on mouse users and missing focus indicators on keyboard users.
* {
outline: 2px solid transparent;
}
*:focus {
outline: 2px solid #007acc;
}
*:focus-visible {
outline: 2px solid #007acc;
outline-offset: 2px;
}
The focus-visible pseudo-class is supported natively in modern browsers. For older environments, polyfill with :focus and :focus-within and a focus-visible class toggled via JavaScript.
document.addEventListener('mousedown', (event) => {
document.documentElement.classList.remove('focus-visible');
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Tab') {
document.documentElement.classList.add('focus-visible');
}
});
A critical failure mode is when focus is not properly managed in modals. The modal should trap focus: when open, all focus must be within the modal until closed. This requires focus() on the modal element, tabindex="-1" on the background, and keydown handlers for Escape and Tab.
Screen readers interpret the DOM structure and ARIA (Accessible Rich Internet Applications) roles, states, and properties. The key to effective implementation is semantic HTML combined with ARIA.
Use native HTML elements for common interactions: <button>, <input>, <label>, <nav>, <article>, <section>. These provide built-in semantics. For custom components, add ARIA roles.
<button
aria-expanded="false"
aria-controls="submenu-1"
id="toggle-submenu"
>
More options
</button>
<div
id="submenu-1"
role="menu"
aria-labelledby="toggle-submenu"
hidden
>
<button role="menuitem" id="item-1">Option 1</button>
<button role="menuitem" id="item-2">Option 2</button>
<button role="menuitem" id="item-3">Option 3</button>
</div>
The aria-expanded state updates dynamically when the button is clicked.
const button = document.getElementById('toggle-submenu');
const menu = document.getElementById('submenu-1');
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', !expanded);
menu.hidden = !expanded;
});
Use aria-live to announce dynamic content. The aria-live attribute on a container announces changes to its content to screen readers.
<div id="status" aria-live="polite">
<p>Form submitted successfully.</p>
</div>
The aria-live value can be off, polite, assertive, or rude. polite announces updates after current speech, assertive interrupts immediately. Use polite for status updates, assertive for errors.
For real-time interactions like search suggestions, use aria-live="polite" on the suggestions container.
const suggestions = document.getElementById('suggestions');
suggestions.setAttribute('aria-live', 'polite');
suggestions.setAttribute('aria-relevant', 'additions text');
The aria-relevant property controls what changes are announced. additions announces new items; text announces content changes.
Users with low vision benefit from high contrast, larger text, and consistent spacing. The system provides prefers-contrast and prefers-reduced-motion media queries. Use them in CSS.
@media (prefers-contrast: high) {
body {
background-color: #000;
color: #fff;
font-weight: 500;
}
button, input, select, textarea {
border: 3px solid #000;
padding: 0.75em 1em;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.1s !important;
animation-iteration-count: 1 !important;
transition-duration: 0.1s !important;
}
}
Ensure all interactive elements are at least 44px by 44px (the recommended minimum touch target). Use min-height and min-width in addition to padding and font-size.
button, [role="button"], [role="switch"], [role="tab"] {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
}
a, [role="link"] {
min-height: 44px;
min-width: 44px;
padding: 10px 12px;
}
For icons, ensure they are accessible via aria-label or aria-labelledby, and that they are scalable with vector images (SVG). Use aria-hidden="true" on icons that are purely decorative.
<button>
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
<span>Like</span>
</button>
A common failure is using font-size without line-height and letter-spacing, leading to crowded text. Ensure adequate spacing:
body {
line-height: 1.5;
letter-spacing: 0.02em;
}
Testing must go beyond manual inspection. Use tools that validate the DOM and provide actionable feedback.
Use axe-core for automated accessibility testing. Run it via CLI with npx axe-core.
npx axe-core --config axe-config.json --output results.json --url https://example.com
The axe-config.json file defines rules and options:
{
"runOnly": {
"type": "tag",
"values": ["wcag2a", "wcag2aa"]
},
"rules": {
"color-contrast": {
"enabled": true,
"options": {
"threshold": 4.5
}
},
"landmark-roles": {
"enabled": true
}
},
"include": [
"body",
"#main-content"
]
}
axe-core reports violations with id, message, help, and url fields. The help field contains a link to a detailed guide on how to fix the issue.
For visual testing, use Pa11y with Puppeteer to capture accessibility audits across multiple viewport sizes and conditions.
{
"name": "Accessibility Audit",
"url": "https://example.com",
"viewport": [
{ "width": 320, "height": 480 },
{ "width": 768, "height": 1024 },
{ "width": 1200, "height": 800 }
],
"checks": [
"color-contrast",
"landmarks",
"aria",
"keyboard-navigation"
],
"output": "json"
}
Run the test with pa11y-ci.
npx pa11y-ci --config pa11y-config.json
Fail the build if any accessibility rule has a critical or major violation.
In SPAs and complex UIs, content is added, removed, and updated without full page reloads. This requires careful management of ARIA states and roles.
For dynamic content, use aria-atomic, aria-busy, and aria-live together.
<div id="feed" aria-live="polite" aria-atomic="true">
<article id="post-1" aria-busy="true">
<h2>Post Title</h2>
<p>Loading content...</p>
</article>
</div>
When content is loaded, set aria-busy="false" and update the content. The screen reader announces the entire feed as atomic, ensuring the user hears the new post in one chunk.
For tabbed interfaces, use role="tablist", role="tab", and role="tabpanel".
<div role="tablist" aria-label="Product information tabs">
<button
role="tab"
id="tab-1"
aria-selected="true"
aria-controls="tabpanel-1"
>
Description
</button>
<button
role="tab"
id="tab-2"
aria-selected="false"
aria-controls="tabpanel-2"
>
Reviews
</button>
</div>
<div
id="tabpanel-1"
role="tabpanel"
aria-labelledby="tab-1"
hidden
>
<p>Product description goes here.</p>
</div>
Use aria-selected and aria-controls to link tabs to their content. When a tab is clicked, update the aria-selected state and toggle hidden on the tabpanel.
A common failure is forgetting to update aria-current on the active tab. Use aria-current="page" for the current page, aria-current="step" for steps in a wizard, and aria-current="location" for location in a breadcrumb.
Custom components must manage focus and state effectively. A common pattern is a select component with an input and a listbox.
<div role="combobox" aria-expanded="false" aria-controls="options-list">
<input
type="text"
role="searchbox"
aria-autocomplete="list"
aria-owns="options-list"
id="search-input"
/>
<ul
id="options-list"
role="listbox"
aria-labelledby="search-input"
hidden
>
<li role="option" id="option-1">Option 1</li>
<li role="option" id="option-2">Option 2</li>
<li role="option" id="option-3">Option 3</li>
</ul>
</div>
When the input has focus, the listbox appears. On keydown of ArrowDown, the listbox expands and highlights the first option. On Enter, the selected option is applied.
Use aria-activedescendant to manage focus in listboxes:
const input = document.getElementById('search-input');
const listbox = document.getElementById('options-list');
const options = Array.from(listbox.querySelectorAll('li'));
input.addEventListener('input', () => {
const filtered = options.filter(opt => opt.textContent.toLowerCase().includes(input.value.toLowerCase()));
listbox.innerHTML = '';
filtered.forEach(opt => listbox.appendChild(opt));
listbox.hidden = false;
});
input.addEventListener('keydown', (event) => {
const activeOption = listbox.querySelector('[aria-selected="true"]');
const index = options.indexOf(activeOption);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (index < options.length - 1) {
options[index + 1].setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', options[index + 1].id);
}
break;
case 'ArrowUp':
event.preventDefault();
if (index > 0) {
options[index - 1].setAttribute('aria-selected', 'true');
input.setAttribute('aria-activedescendant', options[index - 1].id);
}
break;
case 'Enter':
event.preventDefault();
const selected = listbox.querySelector('[aria-selected="true"]');
input.value = selected.textContent;
input.setAttribute('aria-activedescendant', selected.id);
listbox.hidden = true;
break;
}
});
This pattern ensures that all state changes are communicated to assistive technologies and that focus is always synchronized.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy