skip to content

In a client-side-routed app where navigation swaps the page content without a full document load, why do keyboard and screen-reader users lose their place, and what markup makes the fix possible?

level: seniorimportance: should knowfreq 44%

answer

  1. the clicked link is torn out
  2. focus falls back to body
  3. tabbing restarts from the top
  4. headings need a negative tabindex
  5. navigation, not every state change

basics

~20 s

The link that was clicked is removed along with the old view, so focus falls back to the document body: tabbing restarts at the top and nothing is announced. The fix is a focus target in the new view, such as a heading carrying tabindex="-1".

solid answer

~50 s

A real navigation resets focus and gets the new document announced for free. A client-side view swap does neither. The user activates a link, the old view including that link is torn out of the DOM, and focus reverts to `<body>` — so the next Tab starts from the very top of the page, and a screen-reader user gets no signal that anything changed at all. The markup fix is to give the new view a focusable destination: `<h1 tabindex="-1">` for the view title, or a `<main id="main" tabindex="-1">` landmark, then call `.focus()` on it once the new content is in the DOM. Focusing the heading means the new page's title is what gets announced. Use `focus({ preventScroll: true })` if you are restoring scroll position yourself, keep the value at `-1` so the target never becomes a stray Tab stop, and update the document title as well.

code

html · 10 lines
html
<main id="main" tabindex="-1">
  <h1>Search results</h1>
</main>

<script>
  // call this after the new view has been inserted into the DOM
  function focusNewView() {
    document.getElementById('main').focus({ preventScroll: true });
  }
</script>

go deeper

for a junior

Know that swapping the view in JavaScript does not reset focus the way a real page load does, and that the new view needs a focusable target such as a heading with tabindex="-1".

for a middle

Explain the mechanism: the focused link is removed, focus reverts to the body, and tabbing restarts at the top. Say why the target needs a negative tabindex and why focus() before render silently does nothing.

for a senior

Demonstrate the production judgment: which element to focus and why, how to distinguish a navigation from an ordinary state update, handling back/forward, and how you would test it with three keystrokes rather than trusting the framework.

for a principal

Own it as an application-wide contract — one navigation primitive every route goes through, with the focus target and title update handled centrally, so no feature team has to remember it and no route can quietly regress.

## What a real navigation gives you for free When the browser loads a new document, focus is reset to the top of the new page, the tab order restarts there, and assistive technology announces the new document. None of that is something the page author arranged — it is what a document load means. ## What breaks when the router swaps the view A client-side route change is a DOM mutation. The old view is removed, the new one is inserted, and the URL is updated — but the document never changes as far as the browser is concerned. Two things follow. First, focus. It was on the link the user activated, and that link has just been removed from the document. When the focused element disappears, focus reverts to `<body>`. From there the next Tab starts at the first focusable element in the whole page — the skip link, the logo, the entire navigation — every single time the user navigates. On a documentation site with a sidebar of a hundred links, that is unusable. Second, announcement. Nothing tells a screen-reader user that the view changed. The visual transition is invisible to them; unless something moves focus or announces, the software's position in the page and the page itself have silently diverged. ## The markup that makes the fix possible The fix is a focus target that exists in the newly rendered view. It has to be focusable, and headings and landmarks are not focusable by default, so it needs a negative tabindex: ```html <main id="main" tabindex="-1"> <h1 tabindex="-1">Search results</h1> </main> ``` Then, after the new view has committed to the DOM, move focus to it: ```html <script> document.getElementById('main').focus({ preventScroll: true }); </script> ``` `-1` rather than `0` matters: the target must be reachable by `focus()` but must not become an extra Tab stop for everyone on every page. ## Where to put focus The view's `<h1>` is usually the best destination, because focusing it means the title of the new view is the first thing announced — you get the navigation signal and the orientation in one move. The `<main>` landmark is the other reasonable choice, and is often preferable when the heading is not the first meaningful content. What not to focus: the first interactive control in the view, which skips past the content the user came for; and anything in the header or navigation, which puts them back where they were trying to leave. ## Ordering and the silent failure The focus call must happen after the new content is in the DOM. Calling `focus()` on a node that has not been rendered yet does nothing at all, silently — no error, no exception. The same silence is what you get if you forget the `tabindex` on the heading: `focus()` on a non-focusable element is a no-op, and focus simply stays on the body. Both bugs look identical from the outside, which is why this is worth testing explicitly rather than assuming. ## Do not move focus on every update This discipline is for navigations, not for every state change. Filtering a list in place, revealing a detail row, or a background refresh must not yank focus — the user may be typing. Moving focus is an interruption, and it is justified when the user's context has genuinely been replaced. Back and forward through history count as navigations for this purpose and need the same treatment. ## Scroll is not focus Scrolling to the top of the page is a separate concern that routers often handle and that people mistake for the fix. It repositions the viewport for a sighted mouse user and does nothing for the tab order or for what is announced. Handle both, and if you restore scroll yourself, pass `preventScroll: true` so the focus call does not fight your scroll restoration. ## Testing it Tab into the page, activate a navigation link with Enter, then press Tab once. The next focus stop must be inside the new view. If it is the skip link or the site logo, focus went to the body and the fix is not working — regardless of what the scroll position did.

  • Why focus the view's heading rather than its first link or button?
    Focusing the heading announces the new view's title, which orients the user, and leaves the whole view ahead of them in the tab order. Focusing the first control skips past the content they navigated to read and gives them no confirmation of where they landed.
  • What happens if you call focus() on a heading that has no tabindex?
    Nothing, silently. Non-interactive elements are not focusable, so the call is a no-op, focus stays on the body, and the bug looks exactly like forgetting the call altogether. The `tabindex="-1"` is what makes the target valid.
  • Should focus move on every state change in the app, or only on navigation?
    Only on navigation. Moving focus interrupts whatever the user is doing, so it is justified when their context has been replaced — including back and forward through history. In-place updates like filtering a list should leave focus alone.

saying these in an interview costs you the question

  • Assumes the router restores focus automatically
  • Treats scrolling to the top as equivalent to moving focus
  • Calls focus() on a heading with no tabindex and expects it to work
  • Focuses the new view before it exists in the DOM
  • Moves focus on every re-render, not just on navigation

context