Focus management, ARIA and testing
Moving focus deliberately in dynamic interfaces, using ARIA only when needed, and a testing routine that finds real barriers.
Managing focus in dynamic interfaces
When content appears or disappears without a page load, keyboard users can lose their place entirely. Focus rules for the common cases: opening a dialog moves focus into it and keeps it there; closing returns focus to the control that opened it; a newly revealed section receives focus; a removed element's focus moves somewhere sensible rather than back to the top of the document.
// dialog: trap focus inside, restore it on close
const dialog = document.querySelector("#dialog");
const opener = document.activeElement;
function open() {
dialog.showModal(); // native element handles the trap
dialog.querySelector("input")?.focus();
}
function close() {
dialog.close();
opener?.focus(); // give the user their place back
}
// a status message that assistive tech announces without stealing focus
status.textContent = "Draft saved";
// status element: <p id="status" role="status" aria-live="polite"></p>role="alert"interrupts immediately - reserve it for errors, and userole="status"for confirmations.- Never move focus on a timer; it fights the user.
- Anchor links to a section should also set
tabindex="-1"on the target so focus follows the scroll. - Client-side route changes are invisible to assistive tech: announce the new page and move focus to its heading.
ARIA, when it is actually needed
The first rule of ARIA is not to use it. It changes how the accessibility tree is exposed, not how anything behaves - so it can only help where native semantics are genuinely missing, and it can only harm when it contradicts what the element really is.
| Attribute | Use it for | Watch out |
|---|---|---|
aria-label | Naming a control with no visible text | Ignored on some generic elements; keep names short |
aria-labelledby | Naming from existing visible text | Points at element ids, not text |
aria-describedby | Hint or error linked to an input | Announced in addition to the label, not instead of it |
aria-expanded | Disclosure buttons and menus | Must be updated on every toggle |
aria-hidden="true" | Removing purely decorative duplicates | Never on focusable content |
aria-live | Regions whose changes should be announced | Set it up before the content changes, not after |
role="button" | Almost nothing - prefer the element | Does not add keyboard behaviour |
<!-- good: native toggle with correct open state -->
<button type="button" aria-expanded="false" aria-controls="menu">Menu</button>
<ul id="menu" hidden>
<li><a href="/orders">Orders</a></li>
</ul>
<!-- icon-only control needs an accessible name -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true" focusable="false" viewBox="0 0 16 16">...</svg>
</button>A testing routine that finds real problems
- Automated scan first: it clears the mechanical issues (missing labels, contrast, duplicate ids, invalid ARIA) in minutes.
- Keyboard-only pass: complete the main task with Tab, Enter, Space and Escape, watching the focus ring and reading the announcement order.
- Screen reader pass: one flow with NVDA on Windows or VoiceOver on macOS, at least checking that headings and form labels are announced correctly.
- Zoom and reflow: 200% zoom and a 320 px wide viewport with no lost content or two-dimensional scrolling.
- Reduced motion and other preferences: honour
prefers-reduced-motionand confirm nothing depends on animation to be understood. - Fix, then repeat on the template - a fixed component fixes every page that uses it.
# automated pass in CI, fails the build on new violations
npx axe https://localhost:3000 --exit
# or in a Playwright test
npx playwright test a11y.spec.jsFAQ
Does aria-hidden fix duplicate content?
How often should accessibility be tested?
Related
Semantic HTML and keyboard access Semantic layout
Last refreshed 2026-09-18.