Accessibility is mostly a consequence of how you build the DOM, not a separate feature you bolt on at the end. Focus that lands in the right place, controls that announce what they do, errors that are read out loud rather than only turned red — all of it comes down to a handful of attributes and a few habits. This page covers the ones that matter most for JavaScript developers.
Where should the keyboard focus go after you change the page?
Two rules cover most cases. When you open something that takes over, move focus into it. When you close it, put focus back on the control that opened it, so the person is returned to where they were. Forget the second half and a keyboard user who closes a dialog gets dumped back at the top of the page.
<header><p>Tutorsbot</p></header>
<nav aria-label="Sections"><a href="#one">One</a></nav>
<main id="main">
<h1>Settings</h1>
<button id="open-dialog" aria-haspopup="dialog" aria-controls="dialog">Open settings</button>
<div id="dialog" hidden role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<h2 id="dialog-title">Settings</h2>
<input id="nickname" type="text">
<button id="close-dialog">Close</button>
</div>
<p id="status" role="status" aria-live="polite"></p>
<button id="toggle" aria-expanded="false" aria-controls="details">More</button>
<div id="details" hidden>Extra information</div>
<form id="signup">
<label for="email">Email address</label>
<input id="email" type="email" aria-describedby="email-error">
<p id="email-error"></p>
</form>
<button id="icon-only" aria-label="Save to favourites">
<span class="glyph" aria-hidden="true">*</span>
</button>
</main>
const openBtn = document.querySelector("#open-dialog");
const closeBtn = document.querySelector("#close-dialog");
const dialog = document.querySelector("#dialog");
const firstField = document.querySelector("#nickname");
let lastFocused = null;
function openDialog() {
lastFocused = document.activeElement;
dialog.hidden = false;
firstField.focus();
}
function closeDialog() {
dialog.hidden = true;
lastFocused?.focus();
}
openBtn.addEventListener("click", openDialog);
closeBtn.addEventListener("click", closeDialog);
openBtn.focus(); // clicking a button gives it focus
openBtn.click();
console.log(dialog.hidden); // false
console.log(document.activeElement.id); // "nickname"
closeBtn.click();
console.log(dialog.hidden); // true
console.log(document.activeElement.id); // "open-dialog"
The lastFocused variable is the part people leave out. Saving the element that was focused before the change, rather than hard-coding a return target, means the same dialog works correctly when it is opened from several different places.
How do you announce a change that happened without a page reload?
When the page changes under a screen reader user — a search returns results, an item is saved, an error appears — nothing happens visually to draw their attention, and they may never notice. A live region fixes that. It is an ordinary element with role="status" and aria-live="polite" that you write messages into.
Two details matter. The region must already exist in the page before you write to it; inserting a live region and filling it in the same instant often announces nothing. And polite versus assertive is the difference between a message that waits for a gap in the speech and one that cuts in immediately. Use polite for almost everything, and reserve assertive for things that genuinely cannot wait.
const status = document.querySelector("#status");
// The region was already in the page, so writing to it is announced
status.textContent = "3 results found";
console.log(status.textContent); // "3 results found"
console.log(status.getAttribute("role")); // "status"
console.log(status.getAttribute("aria-live")); // "polite"
// Assertive cuts in immediately — use it sparingly
status.setAttribute("aria-live", "assertive");
status.textContent = "Your session expired, please sign in again";
console.log(status.getAttribute("aria-live")); // "assertive"
Because the message is announced when it changes, writing the same text twice in a row announces nothing the second time. If a count goes from 3 to 3 and back to 3, vary the wording or clear the region first, or the person misses the update entirely.
How does a form control get its accessible name?
The accessible name is the label a screen reader announces for a control. There are three ways to supply it, and they are not interchangeable. A <label for="email"> element gives a visible label and is the best option, because sighted and screen reader users get the same thing. aria-label sets the name from an attribute and is the fallback when there is no room for a visible label. aria-labelledby points at another element's text and is useful for a heading that already names a group.
What it is often confused with is aria-describedby, which supplies the description, not the name. A field can have a name from its label and a description pointing at a hint or an error message, and it is common to need both at once.
const email = document.querySelector("#email");
const error = document.querySelector("#email-error");
// The description points at the error message, not at the name
console.log(email.getAttribute("aria-describedby")); // "email-error"
// aria-invalid tells assistive technology the field currently holds bad data
email.setAttribute("aria-invalid", "true");
error.textContent = "Enter a valid email address";
console.log(email.getAttribute("aria-invalid")); // "true"
email.setAttribute("aria-invalid", "false");
error.textContent = "";
console.log(email.getAttribute("aria-invalid")); // "false"
The visible error message and the state of the field have to be changed together, as the last two lines show. A field marked invalid with no message explains nothing, and a message beside a field that is not marked invalid is easy to miss.
How do you build a button that expands a panel correctly?
A show-and-hide control is the most common piece of interactive JavaScript on the web, and it has a well-established pattern. The trigger is a real <button> with aria-expanded set to "false" or "true", and aria-controls naming the panel it toggles. The panel is hidden with the hidden attribute, not with a CSS class that only hides it visually.
const toggle = document.querySelector("#toggle");
const panel = document.querySelector("#details");
toggle.addEventListener("click", () => {
const expanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!expanded));
panel.hidden = expanded;
});
console.log(toggle.getAttribute("aria-expanded")); // "false"
toggle.click();
console.log(toggle.getAttribute("aria-expanded")); // "true"
console.log(panel.hidden); // false
Keeping aria-expanded and the hidden attribute in step is the whole job. Get it wrong and a screen reader announces "collapsed" while the panel is wide open, or the reverse. Because the two live in different places, it is worth setting them together in one place in your code rather than in two.
Why do headings and landmarks matter as much as ARIA?
Before you add a single ARIA attribute, make sure the document structure is right. Screen reader users navigate long pages by heading, by landmark, and by list. A page with a sensible heading hierarchy and one <main> region is far easier to navigate than a page that has tried to compensate for bad structure with a pile of roles.
Landmarks are the regions a page is divided into: <header>, <nav>, <main>, <footer>, and <aside>. When a page has more than one of the same kind, give each one a label so they can be told apart — <nav aria-label="Sections"> rather than two unlabelled navigation regions.
// Landmarks give a screen reader a way to jump between regions
console.log(document.querySelector("main")?.id); // "main"
console.log(document.querySelector("nav")?.getAttribute("aria-label")); // "Sections"
console.log(document.querySelectorAll("h1").length); // 1
console.log(document.querySelector("h2")?.textContent); // "Settings"
The one <h1> matters because it is the page's title, and headings below it should step down one level at a time. Skipping from <h1> straight to <h4> breaks the outline that heading navigation depends on, and no ARIA attribute repairs it.
What makes a custom control usable with the keyboard?
The most reliable way to make something operable by keyboard is not to make it operable by keyboard. A real <button> is focusable, responds to Enter and Space, fires a click event, is announced as a button, and participates in the form. A <div> with a click listener on it does none of that.
When you genuinely need something that is not a button — a custom listbox, a draggable handle, a clickable card — you have to supply the missing behaviour yourself: make it focusable with tabindex="0", give it a role so it is announced correctly, and handle Enter and Space yourself.
const fake = document.createElement("div");
fake.id = "fake-button";
fake.tabIndex = 0;
fake.setAttribute("role", "button");
fake.textContent = "Press me";
document.body.append(fake);
fake.focus();
console.log(document.activeElement.id); // "fake-button" — it is reachable now
// But Enter and Space still have to be handled by hand
fake.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
console.log("activated");
}
});
fake.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
There is a good reason the built-in <button> is preferable: everything on this list is easy to forget, and each omission is invisible until someone tries to use the page without a mouse. Icons also need help. An icon-only button has no visible text, so give it an aria-label, and mark the decorative shape itself with aria-hidden="true" so it is not announced twice.
const iconButton = document.querySelector("#icon-only");
const glyph = iconButton.querySelector(".glyph");
console.log(iconButton.getAttribute("aria-label")); // "Save to favourites"
console.log(glyph.getAttribute("aria-hidden")); // "true"
console.log(glyph.textContent); // "*" — visible, just not announced
The DOM accessibility checklist
- Move focus into an overlay when it opens, and back to the trigger when it closes.
- Put a live region in the page before you write to it, and use
role="status"for polite announcements. - Give every control a name: a
<label for>first,aria-labeloraria-labelledbyif you must. - Keep
aria-invalidand the visible error message in step. - Keep
aria-expandedand thehiddenattribute in step. - Use landmarks and a sensible heading order before reaching for ARIA.
- Prefer a real
<button>over a clickable<div>, and never remove the focus outline.
Try it yourself: turn off your mouse and open a page you built. Tab through every control and note where focus goes and whether you can always see it. Open a modal if there is one and check whether focus moves inside it. Submit a form with an error and see whether a screen reader would have any way to know which field is wrong. Then open the inspector, find the input, and add aria-describedby pointing at an error paragraph that already exists in your markup.