Accessible JavaScript
HTML gives you keyboard support, roles and focus for free. JavaScript can quietly take all of that away. This lesson shows the six places scripts most often break a page, and the small fix for each.
In one minute
- Put click handlers on
<button>and<a href>, not on<div>. Then the keyboard works too. - When script removes or reveals something, decide where focus goes next. Never leave it nowhere.
- Results that arrive later ("3 results", "Saved", errors) need a live region that was on the page before the text changed.
- When script changes the look, it must also change the state:
aria-expanded,aria-pressed. - Check
prefers-reduced-motionin script, never trap the keyboard, and let links and forms work before script loads.
HTML is a house with doors, labels and light switches already fitted. JavaScript is the renovation crew. A careless crew paints over the switches and moves the doors. Your job is to renovate without locking anyone out.
1. The problem: a click handler on a div
A food-delivery app has an "Add to basket" box made from a <div> with a click handler. A mouse user taps it and gets food.
A keyboard user presses Tab and never lands on it. A screen reader user hears plain text, not a button. They go hungry.
A click handler is a function that runs when something is clicked. On a real <button>, the browser also runs it for Enter and Space.
On a <div>, it runs only for the mouse.
Step 1. Choose the markup. Step 2. Press Tab to reach the control, then press Enter and Space. Watch the log.
What happened
The middle option is a trap. tabindex="0" makes the div focusable, but Enter still does nothing.
You would have to add a role, two key handlers and a focus style. The button already has all of it.
- In the lab, which option lets you add the dosa with Space? Only one does.
2. Keyboard events: handle the right keys, and only those
A keyboard event (keydown) tells your script which key was pressed. Use it to add keys people expect.
Do not use it to steal keys that the browser or a screen reader already uses.
| Key | Where | What it should do |
|---|---|---|
| Enter, Space | Buttons | Activate. A real <button> does this for you. |
| Enter | Links | Follow the link. Space scrolls the page instead. |
| Escape | Menus, pop-ups, dialogs | Close it and return focus to the control that opened it. |
| Arrow keys | Composite widgets only: tabs, menus, listboxes, grids | Move between the parts. Elsewhere, leave arrows alone. They scroll the page. |
| Tab, Shift+Tab | Everywhere | Move to the next or previous control. Do not block it. |
| Single letters | Shortcuts | Risky. Screen readers and voice control use letters too. Let users turn them off or change them (WCAG 2.1.4). |
A composite widget is one control made of several parts, like a set of tabs. Widget roles shows the full key patterns. In browse mode, most screen readers catch arrows and letters before your page even sees them. That is normal.
// Close a pop-up with Escape and give focus back
popup.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
popup.hidden = true;
openButton.setAttribute("aria-expanded", "false");
openButton.focus();
}
});
- Open any menu on a website you use. Press Escape. Does it close? Where is focus afterwards?
3. Managing focus after the page changes
Focus is the spot where the keyboard is "pointing". If script deletes the focused element, focus has nowhere to stand.
The browser usually drops it onto <body>, the whole page. A screen reader may go silent or jump to the top.
The fix is one line: after removing an item, call .focus() on something sensible. Usually that is the next item, or the previous one if it was last.
Step 1. Choose a behaviour. Step 2. Press Tab to a "Remove" button and press Enter. Read the focus log.
Your basket
Focus log
function removeItem(li) {
var next = li.nextElementSibling || li.previousElementSibling;
li.remove();
if (next) next.querySelector("button").focus();
else emptyMessage.focus(); // has tabindex="-1"
}
The same rule applies when you open something. When a panel or dialog opens, move focus into it. When it closes, send focus back to the button that opened it (see Managing focus).
- Remove all four items in "Remove, then move focus" mode. Where does focus end up, and why is that better than
<body>?
4. Announcing results that arrive later
Some answers take time: a search, a save, a payment. Sighted users see "Searching…" then "3 results". A screen reader user hears nothing, unless you use a live region.
A live region is an element the screen reader watches. When its text changes, the new text is read out. role="status" is a polite live region: it waits until the user stops typing or reading.
Put the empty live region in the HTML when the page loads. Then change its text. If you add the region and its text at the same moment, many screen readers miss it.
Step 1. Choose a version. Step 2. Type "ma" in the search box. Try "xyz" too, and tick "Pretend the network fails".
For an error that needs action now, role="alert" is stronger: it interrupts. Use it rarely. For most updates, role="status" is right (see Live regions).
- In "Region on the page first" mode, what does the simulated screen reader say for "xyz"? Is that more useful than silence?
5. Keep state attributes in sync
A disclosure is a button that shows or hides a panel. Its aria-expanded attribute tells screen readers if the panel is open.
The browser does not update it for you. If your script shows the panel but forgets the attribute, the screen reader says "collapsed" while it is open.
Step 1. Choose a version. Step 2. Press the "Delivery details" button a few times. Compare what you see with what the screen reader says.
Arrives in about 35 minutes. Rider: Kiran. Leave at the door.
The same goes for aria-pressed on toggle buttons, aria-selected on tabs, and aria-current on the current page link (see States and widget properties).
A good habit: write one function that sets the look and the attribute together.
- In "Out of sync" mode, open the panel. What does a screen reader user think will happen if they press the button now?
6. Respect reduced motion in script too
Some people get dizzy or sick from movement on screen. They can switch on "reduce motion" in their device settings.
CSS can read this with @media (prefers-reduced-motion: reduce). JavaScript animations need to check it too.
var calm = window.matchMedia("(prefers-reduced-motion: reduce)");
function addToBasket() {
if (calm.matches) { showBadgeInstantly(); }
else { flyBadgeToBasket(); }
}
// The setting can change while the page is open:
calm.addEventListener("change", updateAnimations);
Step 1. Press "Add to basket". The badge only moves if your device allows motion.
- Turn on "reduce motion" (Windows: Settings › Accessibility › Visual effects › Animation effects off. Mac: System Settings › Accessibility › Display › Reduce motion). Reload and press the button again.
7. Do not trap focus, and work before script loads
A keyboard trap is a spot that Tab can enter but not leave. WCAG 2.1.2 forbids it.
Scripts cause traps by accident: a handler that cancels Tab, or one that pulls focus back on blur.
Accidental trap
Badinput.addEventListener("keydown", function (e) {
if (e.key === "Tab" && !isValid()) {
e.preventDefault(); // user is stuck
}
});
Let them leave, show the error
Fixedinput.addEventListener("blur", function () {
if (!isValid()) showErrorText();
// focus moves on as normal
});
A modal dialog may keep focus inside on purpose, but only with a way out, like Escape. <dialog> opened with showModal() does this for you.
Progressive enhancement means building the plain HTML version first, then adding script on top. If the script fails or loads slowly, the page still works.
Needs script to work
Bad<span onclick="openMenu()">Menu</span>
<div onclick="search()">Search</div>
Works first, better with script
Fixed<a href="/menu">Menu</a>
<form action="/search" method="get">
<label for="q">Search</label>
<input id="q" name="q" type="search">
<button>Search</button>
</form>
- Press Tab from the top of this page to the bottom. Did you ever get stuck? (You should not.)
Common mistakes
| You see | Why it is a problem | Fix |
|---|---|---|
div.onclick for an action | No keyboard, no role. | Use <button>. |
li.remove() on the focused item | Focus falls to the page; user is lost. | Focus the next item or a heading. |
| "3 results" painted into a plain div | Screen reader users never hear it. | role="status" present from page load. |
Panel shown, aria-expanded unchanged | Screen reader says the opposite of the screen. | Update look and attribute together. |
| Arrow keys or letters hijacked on the whole page | Breaks scrolling and assistive tech shortcuts. | Only inside composite widgets, or make shortcuts optional. |
| JS animation ignores reduce motion | Can cause dizziness or nausea. | Check matchMedia first. |
Remember
- Attach handlers to real buttons and links. You get the keyboard for free.
- Whenever script removes or reveals content, decide where focus goes.
- Late results go into a live region that already exists.
- Change the state attribute every time you change the look.
- Check reduced motion in script, never block Tab, and build the no-script version first.