Accessibility in JavaScript
Accessibility (a11y) is the practice of making sure your interface works for people who do not use it the way you do — keyboard users, screen-reader users, users with low vision, users on a touch screen with one thumb. JavaScript can either help (focus management, live announcements) or actively hurt (custom widgets that swallow the keyboard). This page focuses on the JS-level habits that move the needle.
If you only do four things
Use real semantic elements —
<button>,<a href>,<label>,<input>— before reaching for a<div>with handlers.Every click handler needs a keyboard equivalent.
When something appears, disappears or moves focus, manage focus so the keyboard user does not get lost.
When content updates dynamically, announce it to assistive tech with
aria-liveor by moving focus.
Pair keyboard with click
A real <button> already handles Enter and Space for you. The trouble starts when you attach onclick to a <div>, because the browser will not fire that click on keyboard activation. Either use a real button (best) or wire the keyboard yourself.
keyboard-click.js
// Best — let the browser do the work
<button type="button" onClick={toggle}>Show details</button>
// If you must use a div, you have to add four things:
<div
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
>
Show details
</div>Managing focus
Focus is how a keyboard user knows "I am here". When you open a dialog, navigate a single-page route, or remove the currently focused element, the focus ring can disappear — leaving them stranded. JavaScript needs to put it back somewhere meaningful.
focus-management.js
function openDialog(dialog, firstField) {
dialog.hidden = false;
firstField.focus(); // 1. move into the dialog
}
function closeDialog(dialog, triggerButton) {
dialog.hidden = true;
triggerButton.focus(); // 2. return where we came from
}
// On a single-page route change, move focus to the new heading:
function onRouteChange(newPage) {
const h1 = newPage.querySelector('h1');
h1.tabIndex = -1;
h1.focus();
}Two more focus rules of thumb: never focus() something that is hidden, and never remove the focused element without first moving focus somewhere sensible.
ARIA — only when HTML cannot
ARIA attributes tell assistive tech what a custom element means. They are powerful, and they are also easy to get wrong. The first rule of ARIA: don't use ARIA if a native element would do the same job.
role="button"only when you cannot use<button>.aria-label/aria-labelledbyto name a control that has only an icon.aria-expandedon the trigger of a disclosure widget — flip it betweentrueandfalsein JS.aria-controlsto point a trigger at the element it shows or hides.aria-hidden="true"on purely decorative icons inside an otherwise labelled control.
aria-expanded.js
function toggle(button, panel) {
const open = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
}Announcing dynamic updates
A screen reader user does not see your toast appear. They need an aria-live region — an element that, when its text changes, the screen reader will read out.
live-region.js
// Once, near the root of the page:
// <div id="sr-status" role="status" aria-live="polite" class="sr-only"></div>
const status = document.getElementById('sr-status');
function announce(message) {
status.textContent = ''; // force a real change
setTimeout(() => { status.textContent = message; }, 50);
}
announce('Item added to cart');
announce('5 results found');aria-live="polite"waits for a pause in speech — use for almost everything.aria-live="assertive"interrupts — reserve for genuinely urgent errors.For form errors, you can also move focus to the first invalid field; the label gets announced automatically.
Do not break the link
A surprisingly common antipattern is the JS-only link — an <a> with href="#" and an onclick. Middle-click does nothing, right-click "open in new tab" is broken, screen readers read it as a link to nowhere. If it navigates, give it a real href. If it triggers an action, use a <button>.
real-links.js
// Bad
<a href="#" onClick={openModal}>Open</a>
// Good — it is an action
<button type="button" onClick={openModal}>Open</button>
// Good — it is navigation, real URL even if SPA-intercepted
<a href="/profile" onClick={navigateClientSide}>Profile</a>Test the keyboard, test the reader
No amount of theory replaces five minutes of trying. Unplug your mouse and Tab through your page — can you reach every control? Does focus return to where you expect after closing a dialog? Then turn on the OS screen reader (VoiceOver on macOS, NVDA on Windows) and try the same flow with your eyes closed for a moment.