Why start with real buttons and links?
Most accessibility problems in scripted pages come from one decision: using a <div> or <span> as a control and attaching a click listener to it. It looks the same, and it works with a mouse. It fails everyone else. A <div> cannot be reached with Tab, does nothing when you press Enter or Space, and a screen reader announces it as plain text, so nobody knows it is clickable.
A <button type="button"> gives you all of that for free. It is in the Tab order, fires click on Enter and Space, shows a focus ring, works with voice control software, and announces itself as a button. A <a href> does the same for navigation: it can be opened in a new tab, bookmarked and shared. Use a button when something happens on this page, and a link when the person goes somewhere.
When AI tools generate interactive UI, clickable divs are one of the most common faults. Check every element that has a click listener. If it is not a button, a, input, select or summary, ask why.
<!-- Keyboard users cannot reach or press this -->
<div class="btn">Add to basket</div>
<!-- Works with mouse, keyboard, touch and screen readers -->
<button type="button" class="btn">Add to basket</button>

What keyboard support do custom controls need?
Native controls already handle the keys. You add keyboard code only for behaviour the browser does not know about. The most common one is Escape: anything that pops open (a menu, a dropdown, a dialog, a tooltip) should close on Escape. Listen for keydown and compare event.key, which holds readable names like "Escape", "Enter", " " for Space, and "ArrowDown". The older keyCode and which properties are deprecated; skip them.
If you truly cannot use a button (rare, and usually a sign to rethink), the element needs tabindex="0" to be focusable, a role to say what it is, and a keydown handler for both Enter and Space. Space on a real button also must not scroll the page, so you would need event.preventDefault() too. Writing all that by hand is exactly why the advice is to use the real element.
Test every interaction with the keyboard alone. Put the mouse aside, press Tab to move forward and Shift+Tab to move back, Enter and Space to activate, Escape to dismiss. If you get stuck, or you cannot see where focus is, the control is not finished.
menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
setOpen(false);
button.focus();
}
});Where should focus go when something opens or closes?
Focus is where the keyboard's attention is: the element that receives the next key press. Sighted mouse users rarely think about it, but keyboard and screen reader users depend on it entirely. When your script shows or hides content, decide where focus goes, because the browser will not decide for you.
When a menu or dialog opens, move focus into it, usually to the first useful control, with element.focus(). When it closes, send focus back to the control that opened it. If you skip the second step and the focused element becomes hidden, focus falls back to the <body>, and the next Tab press starts from the top of the page. For someone on a long page that is like being sent back to the start of a queue.
You can check where focus is from the Console with document.activeElement. It returns the focused element, or body when nothing is focused. PlainFinch checks use the same property to confirm focus returned to the right place. Only elements that are focusable can receive focus(): buttons, links, form fields, and anything with a tabindex. Calling it on a plain heading does nothing unless you give the heading tabindex="-1", which makes it focusable by script without adding it to the Tab order.
function openMenu() {
setOpen(true);
menu.querySelector("a").focus();
}
function closeMenu() {
setOpen(false);
button.focus();
}
console.log(document.activeElement);What do aria-expanded and aria-controls tell a screen reader?
A toggle button shows or hides something. Sighted visitors see the result; screen reader users hear the button's state instead. Put aria-expanded="false" on the button in the HTML and flip it to "true" when the content opens. The screen reader then announces "Menu, button, collapsed" or "expanded". Keep it on the button, never on the panel.
aria-controls holds the id of the element the button controls. Support for it varies between screen readers, so treat it as a helpful extra. It costs nothing and documents the link for the next developer too.
Drive the attribute and the visible state from the same value, ideally in one function such as setOpen(isOpen). Bugs appear when two code paths (a click and an Escape handler, for example) update only one of the two. For a button that stays on or off, such as Save or Bold, use aria-pressed instead of aria-expanded.
function setOpen(isOpen) {
menu.hidden = !isOpen;
button.setAttribute("aria-expanded", String(isOpen));
}How do you announce updates with aria-live?
Some changes happen away from where focus is: "Saved", "3 results found", "Added to basket". A screen reader user will not notice unless you announce them. An aria-live region does that. Add an element with aria-live="polite" to the HTML when the page loads, keep it empty, and later write the message into it with textContent. The screen reader reads the new text when it finishes what it is currently saying.
The region must exist before the update. Screen readers start watching live regions when the page loads; a region created and filled at the same moment is often ignored. Reuse one status element for all messages. Use aria-live="assertive" only for urgent problems such as a failed payment, because it interrupts whatever is being read. Keep messages short and avoid announcing every keystroke, which floods the listener.
<p id="status" aria-live="polite"></p>
// script.js
const status = document.querySelector("#status");
status.textContent = "Saved to your recipes";How does the native dialog element help?
The <dialog> element with showModal() handles most of the hard parts of a modal for you. It is supported in all current browsers. Calling dialog.showModal() displays it above the page, makes the rest of the page inert (no clicks, no Tab), moves focus into the dialog, and closes it on Escape. When it closes, browsers return focus to the element that was focused before it opened. Call dialog.close() from a Close button.
Use show() only for non-modal panels that should leave the page usable. A form inside the dialog with method="dialog" closes it on submit without reloading. Hand-built modals made from a <div> need focus trapping, Escape handling, inert backgrounds and focus return written by hand, and AI-generated versions frequently miss one of them. Reach for <dialog> first.
const dialog = document.querySelector("#order-dialog");
document.querySelector("#open-order").addEventListener("click", () => {
dialog.showModal();
});
document.querySelector("#close-order").addEventListener("click", () => {
dialog.close();
});How do you avoid trapping focus by accident?
A focus trap keeps Tab inside one area. That is correct for a modal dialog, where the page behind is not usable anyway, and showModal() handles it. Anywhere else it is a bug. A common accidental trap is a keydown handler that calls event.preventDefault() for every key, which also swallows Tab. Another is a widget that moves focus back to itself on blur, so the person can never leave.
Only prevent the default for the specific keys you handle, inside the if that checks for them. And always provide a way out of anything you open: Escape, a visible Close button, or both.
list.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault(); // only for the key you handle
moveDown();
}
});How do you respect prefers-reduced-motion from JavaScript?
Some people set their system to reduce motion because animation makes them dizzy or ill. CSS can respond with a media query, and scripts that animate (smooth scrolling, sliding panels, auto-playing carousels) should check too. window.matchMedia("(prefers-reduced-motion: reduce)") returns an object whose matches is true when the person asked for less motion.
Use it to choose a quick, instant change instead of the animated one. The setting can change while the page is open, so listen for its change event if your page keeps animating over time. Never use it to remove information, only to remove movement.
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
function scrollToMenu() {
menu.scrollIntoView({ behavior: reduceMotion.matches ? "auto" : "smooth" });
}Field notes
- Start with real
<button>elements, which already handle focus and keys. - Anything that opens should close with Escape.
- When something closes, move focus back to the control that opened it.
aria-expandedtells screen reader users whether a toggle is open.
Easy to get wrong
A div with a click listener is invisible to the keyboard and announced as plain text. A button is focusable, works with Enter and Space, and announces its role.
Hiding the element that holds focus drops focus to the page body. Move it back to the control that opened the menu.
Live regions need to exist before they change. Write into a region that was in the HTML from the start.
Preventing every key blocks Tab, typing and shortcuts. Only handle the keys you need.
Questions people ask
Do I need ARIA if I use real buttons?
Less of it. A real button already has its role and keyboard support. You add ARIA only for state the HTML cannot express, such as aria-expanded on a toggle or aria-pressed on an on/off button.
How do I check focus without a screen reader?
Use the keyboard alone and watch the focus ring. In the Console, document.activeElement tells you exactly which element has focus after each step.
Is it fine to remove the focus outline because it looks ugly?
No. Restyle it with :focus-visible instead. Removing it leaves keyboard users with no idea where they are.
Does the dialog element return focus by itself?
Current browsers return focus to the previously focused element when a modal dialog closes. If you open a dialog from code that changed focus first, test it and call focus() yourself if needed.
When should I use aria-live assertive?
Only for urgent messages that must interrupt, such as an error that blocks progress. Everyday confirmations should be polite.