Accessibility for DOM scripting
Manage focus deliberately, keep ARIA state in sync with the DOM, announce changes with live regions, and support the keyboard a custom widget implies.
Focus management
// open a modal: move focus in, remember where it came from
function openDialog(dialog, opener) {
const previous = opener ?? document.activeElement;
dialog.showModal(); // native focus containment
dialog.querySelector('[autofocus]')?.focus();
dialog.addEventListener('close', () => {
previous?.focus(); // always restore
}, { once: true });
}
// make a non-focusable container focusable for one purpose
panel.tabIndex = -1; // focusable by script, not by Tab
panel.focus({ preventScroll: true });
// keep Tab inside a custom overlay
overlay.addEventListener('keydown', (event) => {
if (event.key !== 'Tab') return;
const focusable = [...overlay.querySelectorAll(
'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'
)].filter((el) => el.offsetParent !== null);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});tabindex="-1"makes an element focusable by script only;tabindex="0"adds it to the tab order - which is a change to the user's keyboard journey, not a styling detail.- Prefer
<dialog>withshowModal(): it brings focus containment, the top layer and Escape handling with no JavaScript. - Never move focus on every render. Focus is the user's position in the page and moving it silently is disorienting.
- The
inertattribute makes a whole subtree unfocusable and hidden from assistive technology, which is the correct way to disable the page behind a modal.
⚠️
A focus trap that cannot be left is worse than no trap. Always leave a keyboard exit - Escape, or a close button that is reachable - and handle the case where a custom widget has no focusable children at all, or Tab stops working entirely.
ARIA state that matches the DOM
// a disclosure: the ARIA state must follow the visible state
function togglePanel(button, panel) {
const expanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!expanded));
panel.hidden = expanded;
}
// a custom listbox
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', String(isSelected));
list.setAttribute('aria-activedescendant', option.id);
// a tab set
tab.setAttribute('role', 'tab');
tab.setAttribute('aria-selected', String(active));
tab.setAttribute('aria-controls', panel.id);
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('aria-labelledby', tab.id);
// decorative versus meaningful
icon.setAttribute('aria-hidden', 'true'); // ignored by screen readers
button.setAttribute('aria-label', 'Close dialog'); // when there is no text| Attribute | Meaning | Keep in sync with |
|---|---|---|
aria-expanded | A region opened or closed | Visibility of the region |
aria-selected | Which item is chosen | The selection state |
aria-current | The current item in a set | The active route or page |
aria-hidden | Ignored by assistive tech | Whether the node is decorative |
aria-disabled | Unavailable but still focusable | The action's availability |
aria-label | An accessible name | The visible text, if any |
The first rule of ARIA is to use the right element instead. A <button> already has a role, keyboard activation and focus behaviour; a div with role="button" needs all three reimplemented and is usually missing one of them.
Live regions and keyboard interaction
<p id="status" role="status" aria-live="polite"></p>
<p id="errors" role="alert" aria-live="assertive"></p>
<div id="results" aria-live="polite" aria-atomic="false"></div>// announce a change: the region must exist before you write to it
status.textContent = 'Saved 3 changes';
status.textContent = 'Saved 4 changes'; // a different string, or nothing is announced
// for a list, announce a summary rather than 500 items
results.setAttribute('aria-live', 'polite');
results.textContent = 'Showing 20 of 340 results';politewaits for a pause;assertiveinterrupts. Use assertive only for errors the user must know about now.- The live region must be in the DOM before the change. Injecting the region and its text together is frequently missed by screen readers.
- Setting the same text twice announces nothing - the change event is what triggers the announcement.
- A custom widget needs the keyboard model its role implies: Enter and Space for a button, arrow keys for a listbox or menu, Home and End for a range.
// keyboard model for a custom listbox
list.addEventListener('keydown', (event) => {
const index = options.indexOf(document.activeElement);
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
options[Math.min(index + 1, options.length - 1)].focus();
break;
case 'ArrowUp':
event.preventDefault();
options[Math.max(index - 1, 0)].focus();
break;
case 'Home':
event.preventDefault();
options[0].focus();
break;
case 'End':
event.preventDefault();
options[options.length - 1].focus();
break;
}
});FAQ
How do I test that focus management works?
Drive it with the keyboard only: Tab through the page, open the overlay, press Escape, and check that focus returns to the control you came from. A quick automated check is to assert
document.activeElement after each interaction, which is exactly what an assistive technology follows.When should I use aria-live?
When content changes without the user moving focus and they need to know - a save confirmation, a results count, a validation error. Not for every state change; an over-announcing page is unusable, because the screen reader never stops talking.
Related
Forms, validation and user input Geometry, scrolling and visibility
Last refreshed 2026-09-18.