JavaScriptAccessibility in JavaScript

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-live or 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

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>
Why links are not buttons
An `<a href>` is for navigation; a `<button>` is for in-page actions. Screen readers announce them differently, and the keyboard shortcuts to jump between them differ. Don't make a link out of a button or vice-versa just for the styling.
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

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-labelledby to name a control that has only an icon.

  • aria-expanded on the trigger of a disclosure widget — flip it between true and false in JS.

  • aria-controls to 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

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

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

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.

Automated checks catch the easy half
Run **axe-core** or Lighthouse in CI. They will catch missing labels, low contrast and bad ARIA usage. The other half — focus order, keyboard traps, meaningful announcements — still needs a human to feel out.
A11y is a UX feature, not a checklist
Most accessibility wins also improve the experience for everyone: clearer labels, predictable focus, fewer unexpected motions. If you ship a feature that's usable by keyboard and screen reader, you have probably shipped a feature that's usable, full stop.