8 min read

The Bypass Mechanism Screen Reader Users Still Don't Have

Skip links are a well-established pattern: a link at the very top of a page, invisible until it receives focus, that jumps a keyboard user straight past the header and its navigation to the main content. They exist because a header full of repeated nav links is something a keyboard user, whether or not they're also running a screen reader, has usually sat through once already and has no wish to sit through again. What that repetition costs depends on how each person moves through the page. A sighted keyboard-only user has to Tab across every one of those links to get past them. A screen reader user in browse mode, which doesn't skip non-interactive content the way Tab does, has to sit through the same links, and everything around them, read out one at a time. Skip links solve both at once: sitting first in the DOM, they're the first thing anyone reaches no matter which way they're moving through the page, and activating one drops both audiences straight into the main content, past the header in one move.

That's also why skip links work the way they do: tabbable, and visible the moment they receive focus. Being tabbable makes the link the first stop for anyone moving by keyboard, sighted or not. Being visible on focus is what makes it usable for the sighted half of that audience: a blind user hears the link announced regardless, but a sighted keyboard user needs to see "skip to main content" appear on screen to know it's there and what pressing Enter will do. Take away either property and the pattern breaks for someone.

A long code block, a large data table, a wall of preformatted text: none of that is a set of tab stops. It's not interactive, so a sighted keyboard-only user scrolls past it with the arrow or space key, the same as a mouse user reading with their eyes, and only reaches for Tab when there's something worth interacting with in or after it. A screen reader user gets no such shortcut. Browse mode reads the DOM in document order regardless of what's focusable, so a five-hundred-line code sample is five hundred stops to arrow through before reaching whatever comes next. This was never a problem skip links solved, because it only shows up for people navigating by screen reader. The fix belongs to them alone.

That's the shape of a bypass-block component: one that inverts both of the skip link's defining traits. The link carries tabindex="-1", so it's never a Tab stop, because a sighted keyboard user already gets past the block for free and has no reason to land on a link they don't need. It also stays visually hidden for good, never surfacing even on focus, because there's no sighted audience left to surface it for.

<a href="#end-of-code-1" tabindex="-1" class="sr-only">
  Skip code block
</a>

<pre><code>
  ...
</code></pre>

<span id="end-of-code-1" tabindex="-1" class="sr-only">
  End of code block
</span>

Reversing both traits is deliberate, but it isn't free. "Screen reader user" and "blind user" aren't the same population: plenty of people with low vision or dyslexia, and others who run a screen reader for reasons that have nothing to do with blindness, are looking at the screen while it talks to them. For that audience, a link that's invisible even on focus produces a moment with nothing behind it: they hear "skip code block," then later "end of code block," and nothing on screen changes either time. That's a real point of confusion, and there's no version of this pattern that avoids it. Fixing it would mean giving the link a visible focus state, and a visible, tabbable version would plant an extra Tab stop at every bypassed block on the page rather than once at the top, exactly the busywork skip links exist to remove. I'd call that a fair trade: a few seconds of unexplained audio, on a feature that only fires where a block actually warrants bypassing, against the alternative of reading through that block in full, every time.

The destination side of the pattern matters just as much, starting with why there's a labeled destination there at all rather than a bare jump. Landing silently past the block leaves a screen reader user guessing: did the jump clear the whole thing, or drop them somewhere in the middle with content still unread ahead? An announced "end of code block" answers that the moment the jump lands, confirming the whole block is behind them instead of leaving them to arrow around and check. Getting there also requires the destination to actually take focus, not just sit at the far end of a URL fragment: tabindex="-1" on the <span> is what does that, without inventing a fake focusable element or reaching for the old &nbsp;-in-an-anchor workaround. In testing, that's what let NVDA move document.activeElement cleanly onto the span and resume browse-mode reading right at "end of code block," with no residual line and no dead scroll.

None of this is something WCAG asks for by name. Success Criterion 2.4.1, Bypass Blocks, is explicit about content "that is repeated on multiple web pages": that's the header and the nav, not a code sample that shows up once, in the middle of one article. A literal reading of the criterion doesn't reach this case, and it's worth saying so rather than dressing this up as a compliance requirement it isn't. What it shares with 2.4.1 is the underlying reasoning: people are entitled to move past content that doesn't serve them without being forced through it a piece at a time, the same as keyboard users already are for repeated blocks. The rest of WCAG's language still applies once the component exists. The link needs an accessible name that says what it does, "skip code block," not "skip", which is 2.4.4, Link Purpose (In Context) and 4.1.2, Name, Role, Value doing their ordinary work. The destination has to sit somewhere sensible in focus order once it's reached, which is 2.4.3, Focus Order. None of that is new. What's missing from the standard isn't the accessible-name requirement or the focus-order requirement. It's any criterion that names this specific case at all.

One objection is worth heading off directly: doesn't tabindex="-1" on a link violate Success Criterion 2.1.1, Keyboard, by pulling an interactive element out of reach? It doesn't. 2.1.1 requires that functionality be operable through a keyboard interface, not that every interactive element sit in the sequential Tab order. The Understanding document's own examples include a search form whose submit button never receives focus at all, its functionality staying keyboard-accessible because pressing Enter in the text field beside it triggers the same action. Sequential focus is one route to keyboard operability, not the only one. The more relevant fact here is the one already established: sighted keyboard users have no functional need for the bypass link, because the code block was never part of their path through the page to begin with. Excluding an affordance from Tab order, when the people who'd otherwise reach things by Tab don't need it, isn't a 2.1.1 problem. It would be one if the functionality were unreachable by keyboard for someone who did need it, and it isn't: it's reachable, just through the browse-mode reading stream rather than Tab, the interface its intended users are already using.

In practice this only pays for itself on content that's both non-interactive and long enough to cost something: a code sample, a large table, a block of preformatted text, an image description that runs several sentences. A three-line paragraph doesn't need a bypass link any more than it needs a table of contents. That judgment call belongs to whoever authors the content, which is why this works best as an opt-in, per-block feature rather than something a page renders everywhere the way a skip link does.

None of this makes the block bypass a replacement for a skip link, or the reverse. Skip links remove tab stops that were always there for everyone. Bypass blocks remove content that was never a tab stop for anyone, except the one group still forced to encounter it a piece at a time.

One more thing worth saying plainly: skip links get overloaded more often than they should. It's common to find a "skip navigation" link followed by a "skip to search" link, a "skip to footer" link, sometimes a "skip to chat" link, each one promoted into the same invisible-until-focus slot at the top of the page. That's a bad practice. If a destination is worth surfacing as a shortcut, it's worth surfacing to everyone, not only to people navigating by keyboard, and it belongs in the page's actual navigation, primary or secondary, where every visitor can see and use it. A skip link isn't secondary navigation in disguise. It exists to do one job, get a keyboard user past the page's repeated header content and into the main content, and it should keep doing exactly that job and nothing else. That's also why I'd rather call the component a page skip link than just a skip link: naming the page in the name keeps its scope honest, one destination, the one thing every page has exactly once.