Accessibility · Keyboard and Focus

Skip Links and the Sequential Navigation Starting Point

Why a bypass link works even though it usually points at a non-focusable heading, how the browser positions the tab sequence at the fragment target, and what single-page routing breaks.

A skip link is a hyperlink, usually invisible until focused, that jumps to the main content. It is the oldest bypass mechanism on the web and it works by a route most authors do not expect: not by focusing the target, but by moving the browser’s sequential focus navigation starting point. The distinction explains why some skip links work perfectly while looking broken in DevTools, and why others appear to do nothing at all under client-side routing.

A site’s navigation block sits before its content in document order, which means it sits before its content in the tab sequence. On a page with a header containing a logo link, a primary navigation of eight links, a search field, and a secondary navigation of five links, a keyboard user pays sixteen Tab presses before reaching the first control in the main content — on every page of the site. The cost is not sixteen presses once; it is sixteen presses per navigation, multiplied by the number of pages visited.

WCAG 2.4.1 requires a mechanism to bypass repeated blocks of content. Three are recognised in practice: a skip link, a properly marked landmark region that assistive technology can jump to, and a collapsed disclosure for the navigation. The landmark approach helps screen reader users, who can navigate by region, and does nothing for a sighted keyboard user, who has no such command. The skip link helps both, which is why it is the standard answer.

Following a fragment link runs three steps in order: the target is scrolled into view, the focusing steps are run for the target with the document’s viewport as the fallback, and the sequential focus navigation starting point is moved to the target. The starting point is a position in the document, not an element, and setting it does not require the target to be focusable.

The middle step is the one authors misread. Running the focusing steps for a <main> element with no tabindex does not focus it, because it is not a focusable area; the steps fall through to the fallback, the viewport. So document.activeElement reports the body, and the next Tab press starts from the starting point — the main element — landing on the first focusable element at or after it.

That explains the diagnostic that confuses people. Inspecting document.activeElement after activating a skip link shows <body>, which looks like failure. The correct test is to press Tab and observe where focus lands. If it lands inside the main region, the skip link works.

Two variants of the pattern exist and behave differently:

A link to a focusable target — #main on an element carrying tabindex="-1" — is focused outright, because the focusing steps succeed on the first try, and document.activeElement changes. A screen reader then announces the region, provided the region has an accessible name. This is the more explicit form and the one that survives routing, discussed below.

A consequence worth noting: Shift+Tab immediately after a skip link moves backwards from the target, which lands on the last focusable element before it — typically the end of the navigation block the link was meant to bypass. That is how the walk is defined to work, and it is occasionally surprising to anyone who expects the link to have enclosed them in the region.

The plain fragment link has one advantage over the scripted form: the URL carries the fragment, so a user can bookmark or reload into the position, and the browser re-runs the same three steps. The scripted form leaves the URL unchanged.

Positioning and the visibility problem

The classic skip link is hidden off-screen until focused. The modern form uses a transform or a clip-path, and the important constraint is that the element must not be removed from the rendering tree. display: none and visibility: hidden both remove the link from the tab sequence, so a skip link hidden either way is not reachable and therefore does not exist as a mechanism.

.skip-link {
  position: absolute;
  left: 0;
  top: 0;
  transform: translateY(-120%);   /* off the top edge, still rendered and still focusable */
  background: Canvas;
  color: CanvasText;
  padding: 0.5rem 1rem;
  z-index: 100;
}

.skip-link:focus {
  transform: translateY(0);
}

transform is preferable to left: -9999px for two reasons. A large negative offset expands the document’s scrollable area in some layouts, and on a page whose content is centred, an absolutely positioned element at a large negative offset can create a horizontal scrollbar. And a transform does not affect layout at all, so the reveal does not shift anything.

The reveal must happen on :focus, not on :focus-visible alone. A skip link is focused almost exclusively by keyboard, so the two coincide in practice, but using :focus means the link is visible whenever it holds focus by any route — including when a browser restores focus to it after a back navigation.

A second positioning detail: the link must be the first focusable element in the document. If a cookie banner or an announcement bar precedes it, the banner is what the user must dismiss first, and the skip link is no longer the first thing Tab reaches. Ordering these correctly is a layout decision that is easy to make wrong when the banner is injected by a tag manager after the document is parsed.

The target’s own focusability

Whether the target should carry tabindex="-1" is a genuine choice with consequences in both directions.

Without tabindex="-1", the target is not focused. A screen reader user who activates the link hears nothing: the viewport scrolls and the virtual cursor does not move, because no focus change and no announcement occurred. The user’s next action, a Tab press, does land in the region, so the mechanism works for sighted keyboard users and is weak for screen reader users.

With tabindex="-1", the target is focused, so document.activeElement changes and the region is announced by name if it has an accessible name. The tabindex="-1" also removes the element from the tab sequence, so it does not become an extra stop. This is the strictly better form and costs one attribute.

The catch is that focusing an element scrolls it into view, and browsers scroll a focused element to the top of the viewport by default, which on a target that is already at the top of the page produces no visible movement. scroll-margin-top on the target offsets that, and is also what makes a fragment jump clear a sticky header.

What single-page routing breaks

In a client-side routed application, navigating to a new view does not load a document. The fragment link’s mechanism depends on document navigation, so three things fail at once.

The URL may not change to include the fragment, or it may change without a navigation, and either way the browser does not re-run the fragment scroll or reset the starting point. Focus stays where it was — on the link that was just activated, which is now pointing at a page the user has left. The next Tab press continues from the navigation block of the old page, and the new content is never reached.

The standard remedy is to move focus explicitly after the route change: focus the new view’s heading or main container, which carries tabindex="-1", and reset the scroll position. The order matters — the DOM must be updated before the focus call, or the call targets the old view’s element and is either refused or lands on a node that is about to be replaced. Frameworks that render asynchronously require the focus call to run after the render commits, and the check described in Restoring Focus After the Focused Element Disappears — read back document.activeElement — is what catches the case where the call ran too early.

A skip link inside a routed view also needs care. If the link is rendered by the layout rather than the page, it persists across navigations and its href="#main" points at an id that is replaced on every route change. The id remains valid if the layout owns the main element; if the page owns it, the link must be re-rendered or its href recomputed.

Measuring whether the bypass works

The check is behavioural and takes one pass: load a page, press Tab once, confirm the skip link appears, press Enter, press Tab again, and confirm focus is on the first control in the main region rather than on a navigation item. If the second Tab lands in the navigation, either the target id is wrong, the target sits before the navigation in the tree, or the fragment scroll was suppressed.

The last failure is common and invisible in markup: a router that calls preventDefault() on link clicks globally, or a scroll handler that intercepts fragment navigation to animate the scroll. Both suppress the browser’s own fragment handling, and with it the starting-point update, leaving a link that scrolls the page and does nothing for the tab sequence.

For a page with several repeated blocks, the bypass problem recurses: the main content may itself contain a long list, and the user may want to skip past it too. Landmark regions handle this for assistive technology. For keyboard users the answer is usually not more skip links but a better structure — a list that is genuinely long is a candidate for the composite-widget treatment described in Roving tabindex and Active Descendant Compared, which collapses it to one stop and makes skipping it unnecessary.

References

  1. HTML Living Standard — Sequential focus navigation starting point — WHATWG
  2. Web Content Accessibility Guidelines 2.2 — Success Criterion 2.4.1 Bypass Blocks — W3C
  3. HTML Living Standard — Fragment identifiers — WHATWG
  4. MDN Web Docs — Document: activeElement property — MDN