Accessibility (a11y)
2026-08-08 · 7 min read
Accessibility is not a checklist item added at the end of a project. It's a set of constraints that, when designed for from the start, produce better UIs for everyone — keyboard users, screen reader users, users with motor impairments, and users on touch-only devices.
The short version: use the right HTML element for the job, reach for ARIA only when native elements fall short, and test with a keyboard and a screen reader.
Semantic HTML
The browser gives native elements keyboard reachability and screen reader semantics for free. A <button> is focusable, activatable with Enter and Space, and announced as a button by screen readers. A <div onClick> is none of those things — you'd need to add tabindex="0", role="button", and keyboard event handlers to replicate what you get for free.
<!-- Wrong: requires manual keyboard and ARIA work -->
<div onClick={handleSubmit} className="btn">Submit</div>
<!-- Right: all behavior built in -->
<button onClick={handleSubmit}>Submit</button>
The same applies to <a> for navigation, <input> for form fields, <select> for dropdowns, and <details>/<summary> for disclosure widgets. Reach for the native element first.
ARIA roles and patterns
ARIA (Accessible Rich Internet Applications) fills the gap for complex UI patterns that have no native HTML equivalent. The rule: ARIA supplements semantics, it doesn't replace them. Never use ARIA on a native element that already has the right role.
Feed and articles
<div role="feed" aria-busy="true"> <!-- aria-busy while loading -->
<article role="article" aria-posinset="1" aria-setsize="50">
<!-- post content -->
</article>
<article role="article" aria-posinset="2" aria-setsize="50">
<!-- post content -->
</article>
</div>
role="feed" signals an infinite-scrolling list of articles. aria-posinset and aria-setsize tell screen readers the item's position in the set, so users know where they are as they navigate.
Modal dialogs
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirm Delete</h2>
<p>This action cannot be undone.</p>
<button>Cancel</button>
<button>Delete</button>
</div>
aria-modal="true" tells screen readers to treat content outside the dialog as inert. Focus must be trapped inside the dialog while it's open — tabbing past the last focusable element should wrap back to the first. On close, return focus to the element that triggered the dialog.
Autocomplete / combobox
<input
role="combobox"
aria-expanded="true"
aria-haspopup="listbox"
aria-autocomplete="list"
aria-controls="results-listbox"
/>
<ul role="listbox" id="results-listbox">
<li role="option" aria-selected="false">React</li>
<li role="option" aria-selected="true">Redux</li>
</ul>
The combobox pattern requires the combobox → listbox → option hierarchy. aria-expanded tracks whether the list is open. aria-selected marks the currently highlighted option.
Menus and dropdowns
<button aria-haspopup="menu" aria-expanded="false" aria-controls="nav-menu">
Options
</button>
<ul role="menu" id="nav-menu">
<li role="menuitem">Edit</li>
<li role="menuitem">Delete</li>
</ul>
aria-haspopup="menu" announces to screen readers that activating this button opens a menu. aria-expanded reflects open/closed state and must be updated in JS when the menu toggles.
Data tables and grids
For read-only tabular data, use <table> with proper <thead>, <th scope="col">, and <td>. For interactive spreadsheet-like UIs where cells are editable or selectable:
<div role="grid" aria-label="Spreadsheet">
<div role="row">
<div role="columnheader">Name</div>
<div role="columnheader">Value</div>
</div>
<div role="row">
<div role="gridcell">Revenue</div>
<div role="gridcell" aria-selected="true" tabindex="0">$1,200</div>
</div>
</div>
Identification
<!-- Label by visible text elsewhere in the DOM -->
<section aria-labelledby="section-heading">
<h2 id="section-heading">Recent Posts</h2>
</section>
<!-- Label when no visible text is available -->
<button aria-label="Close dialog">✕</button>
<!-- Additional description for complex controls -->
<input aria-describedby="email-hint" />
<p id="email-hint">Use your work email address</p>
Prefer aria-labelledby over aria-label when there's visible text — it links the element to its visible label so sighted and non-sighted users hear the same thing.
Skip links
A skip link lets keyboard users jump past repeated navigation to the main content — without it, every page load requires tabbing through the entire nav before reaching anything useful.
<a href="#main-content" class="skip-link">Skip to content</a>
<!-- Visually hidden until focused -->
<style>
.skip-link {
position: absolute;
transform: translateY(-100%);
}
.skip-link:focus {
transform: translateY(0);
}
</style>
<main id="main-content">...</main>
Keyboard navigation
Every interactive control must be reachable and operable by keyboard alone. Tab through your UI — if you can't reach it with Tab or activate it with Enter/Space, it's broken for keyboard users.
Tab order
Tab order follows DOM order, not visual order. If CSS order, position: absolute, or grid placement creates a visual order that differs from DOM order, keyboard navigation and screen reader reading order will be wrong.
Focus management
tabindex="-1" makes an element programmatically focusable without adding it to the natural tab order. Use it for elements that receive focus via JavaScript:
// Move focus to a newly opened panel without polluting tab order
panelRef.current.focus();
// The panel has tabindex="-1" so it's focusable but not in the tab sequence
Modal focus trapping
When a modal opens, focus must stay inside it. When it closes, focus must return to the trigger:
function openModal(triggerEl) {
const modal = document.querySelector('[role="dialog"]');
const focusable = modal.querySelectorAll('button, [href], input, [tabindex]:not([tabindex="-1"])');
const first = focusable[0];
const last = focusable[focusable.length - 1];
last.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); first.focus(); }
});
first.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); last.focus(); }
});
first.focus();
modal.addEventListener('close', () => triggerEl.focus(), { once: true });
}
Arrow key navigation
Menus, listboxes, and grids use arrow keys for internal navigation — Tab moves between components, arrows move within them:
menu.addEventListener('keydown', (e) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
const i = items.indexOf(document.activeElement);
if (e.key === 'ArrowDown') items[(i + 1) % items.length].focus();
if (e.key === 'ArrowUp') items[(i - 1 + items.length) % items.length].focus();
if (e.key === 'Escape') closeMenu();
});
Screen reader announcements
Screen readers read static content on navigation. For dynamic updates — new posts loading, errors, status changes — you need aria-live regions to announce changes without the user navigating to them.
<!-- Polite: waits for the screen reader to finish what it is saying -->
<div aria-live="polite" aria-atomic="true">
3 new posts available
</div>
<!-- Assertive: interrupts immediately — use sparingly -->
<div aria-live="assertive">
Connection lost. Reconnecting...
</div>
aria-live="polite" is the right default — it queues the announcement without interrupting. Use aria-live="assertive" only for urgent errors that require immediate attention (connection failure, authentication expiry, form submission failure).
aria-atomic="true" announces the entire region's content when any part changes, rather than just the changed portion. Use it when partial announcements would be confusing.
Announce all four data states:
// Loading
setAnnouncement('Loading posts...');
// Success
setAnnouncement(`${posts.length} posts loaded`);
// Error
setAnnouncement('Failed to load posts. Try again.');
// Empty
setAnnouncement('No posts found');
Motion
Animations and transitions can cause discomfort or nausea for users with vestibular disorders. The prefers-reduced-motion media query lets users opt out at the OS level — honor it.
/* Default: animations on */
.skeleton {
animation: shimmer 1.5s infinite;
}
.modal {
transition: transform 300ms ease;
}
/* Reduced motion: disable or replace with instant/fade */
@media (prefers-reduced-motion: reduce) {
.skeleton {
animation: none;
}
.modal {
transition: opacity 150ms ease; /* fade instead of slide */
}
}
Disable: shimmer animations, slide transitions, parallax effects, autoplay video. Replace with: instant swaps, simple fades, or static states.
Touch targets
Interactive elements must be at least 44×44 CSS pixels to be reliably tappable on mobile (WCAG 2.5.5). A 16px icon button fails this — add padding to increase the tap area without changing the visual size:
.icon-button {
width: 16px;
height: 16px;
padding: 14px; /* tap area: 44×44 */
}
Or use a transparent pseudo-element to extend the hit area without affecting layout:
.icon-button::after {
content: '';
position: absolute;
inset: -14px;
}
The pattern reference
| Component | ARIA pattern |
|---|---|
| News feed | role="feed" + role="article" |
| Search autocomplete | role="combobox" + role="listbox" + role="option" |
| Modal dialog | role="dialog" + aria-modal="true" + focus trap |
| Dropdown menu | role="menu" + role="menuitem" + arrow keys |
| Data table | <table> + <th scope> |
| Spreadsheet | role="grid" + role="row" + role="gridcell" |
| Notification | aria-live="polite" |
| Critical alert | aria-live="assertive" |