Feedback and dialogs Required
Put focus somewhere sensible after every change
After a client-side page change, move focus to the new page's heading and update the title. When the focused element disappears, move focus to the next item, the previous item or the control that opened it.
How to do it
Routers in single-page apps change the content without a page load, so screen readers hear nothing. On each route change, set document.title, give the h1 tabindex="-1" and focus it. After deleting a row, focus the next row, or the previous one if it was last. After closing a dialog, focus returns to the opener, as dialog element describes. Focus must never fall back to body.
Example
export function afterRouteChange(title: string) {
document.title = `${title} | Example Books`;
const heading = document.querySelector<HTMLElement>('main h1');
if (!heading) return;
heading.tabIndex = -1;
heading.focus();
}
export function focusAfterRemoval(removed: HTMLElement) {
const next = (removed.nextElementSibling ?? removed.previousElementSibling ?? removed.parentElement?.closest('section')) as HTMLElement | null;
removed.remove();
if (next) { next.tabIndex = next.tabIndex >= 0 ? next.tabIndex : -1; next.focus(); }
}
def test_focus_moves_to_new_heading(page):
page.goto("https://staging.example.com/orders")
page.get_by_role("link", name="Customers").click()
page.wait_for_url("**/customers")
assert page.evaluate("document.activeElement.tagName") == "H1"
assert page.title().startswith("Customers")
How to check
A browser test follows an in-app link, then deletes the last row of a list. After each step, document.activeElement is not body, and after the link it is the new h1.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers app interface playbook: https://agentwranglers.se/playbook/app-ui/feedback-and-dialogs/managed-focus.