How to test a skip to content link

Press Tab once from the top of the page, confirm the link is now on screen, press Enter, then read document.activeElement. A working skip link takes the first tab stop, becomes visible while it holds focus, and leaves focus on the target. Scrolling to the target without moving focus is the usual failure.

Why check this

Run this on every page template before release, and again after any change to the header component. A link that scrolls without moving focus looks correct in a screenshot. The cost falls on one group of users. A keyboard user who activates the link on a router-driven page lands nowhere: focus is still on the link, now scrolled above the top of the window, and the next Tab returns to the navigation.

The check has three parts and they fail independently. The link has to be reachable, it has to be visible while it holds focus, and activating it has to move focus. Reachability and visibility are covered on their own by how to test keyboard navigation on a website and how to check visible focus indicator. This page measures all three in one walk, because a skip link tends to break all three together.

Prerequisites

// skip-demo.mjs  -  three versions of one skip link. node skip-demo.mjs
import { createServer } from 'node:http';
const page = (target, hide) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Skip link demo</title><style>
 body{font:16px system-ui;margin:0}
 nav a{margin-right:.75rem}
 .skip{position:absolute;${hide}}
 .skip:focus{position:static;display:inline-block;background:#0f766e;color:#fff;padding:.4rem .8rem}
 .hero{height:900px;background:#f5f5f4}
 main{padding:1rem;height:1200px}
</style></head><body>
<a class="skip" href="#main">Skip to content</a>
<nav><a href="/a">Products</a><a href="/b">Pricing</a><a href="/c">Docs</a><a href="/d">Support</a><a href="/e">Blog</a></nav>
<div class="hero"></div>
<main id="main"${target}><h1>Quarterly report</h1><p>Revenue rose.</p><a href="/x" id="inmain">Download the PDF</a></main>
</body></html>`;
const routes = {
  '/':      page('', 'left:-9999px'),               // target has no tabindex
  '/fixed': page(' tabindex="-1"', 'left:-9999px'), // target can take focus
  '/gone':  page(' tabindex="-1"', 'display:none'), // the link itself is unreachable
  // A router that cancels the default action and scrolls instead.
  '/spa':   page('', 'left:-9999px').replace('</body>',
    `<script>document.querySelector('.skip').addEventListener('click', e => {
      e.preventDefault(); document.getElementById('main').scrollIntoView(); });<\/script></body>`),
};
createServer((req, res) => res.end(routes[req.url] ?? routes['/']))
  .listen(8841, () => console.log('http://localhost:8841/'));

Steps

  1. Step 1.

    Start the demo server in its own terminal.

    node skip-demo.mjs
    
    http://localhost:8841/
  2. Step 2.

    Measure the first tab stop focused and blurred, activate it, and read where focus went. The loop descends open shadow roots, for the reason given in the keyboard navigation check.

    // skip-link.mjs  -  node skip-link.mjs <url>
    import { launch } from 'puppeteer-core';
    const url = process.argv[2];
    const browser = await launch({ channel: 'chrome', headless: true });
    const page = await browser.newPage();
    await page.setViewport({ width: 1280, height: 800 });
    const who = () => page.evaluate(() => {
      let e = document.activeElement;
      while (e?.shadowRoot?.activeElement) e = e.shadowRoot.activeElement;
      if (!e || e === document.body) return '(body)';
      const r = e.getBoundingClientRect();
      return `${e.tagName.toLowerCase()}${e.id ? '#' + e.id : ''} "${e.textContent.trim().slice(0, 18)}"`
        + ` rect=${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)} tabindex=${e.tabIndex}`;
    });
    const y = () => page.evaluate(() => window.scrollY);
    await page.goto(url, { waitUntil: 'networkidle2' });
    await page.keyboard.press('Tab');
    console.log('1st Tab, focused : ' + await who());
    console.log('the same, blurred: ' + await page.evaluate(() => {
      const e = document.activeElement; e.blur();
      const r = e.getBoundingClientRect();
      return `rect=${Math.round(r.left)},${Math.round(r.top)} ${Math.round(r.width)}x${Math.round(r.height)}`
        + ` display=${getComputedStyle(e).display}`;
    }));
    await page.goto(url, { waitUntil: 'networkidle2' });
    await page.keyboard.press('Tab');
    const href = await page.evaluate(() => document.activeElement.getAttribute('href'));
    console.log('href             : ' + href);
    if (!href?.startsWith('#')) {
      console.log('the first tab stop is not a fragment link, stopping');
      await browser.close(); process.exit(0);
    }
    console.log('scrollY before   : ' + await y());
    await page.keyboard.press('Enter');
    await new Promise((r) => setTimeout(r, 300));
    console.log('after Enter      : ' + await who());
    console.log('scrollY after    : ' + await y());
    await page.keyboard.press('Tab');
    console.log('next Tab         : ' + await who());
    await browser.close();
    
    node skip-link.mjs http://localhost:8841/
    
    1st Tab, focused : a "Skip to content" rect=0,0 133x34 tabindex=0
    the same, blurred: rect=-9999,0 107x21 display=block
    href             : #main
    scrollY before   : 0
    after Enter      : (body)
    scrollY after    : 921
    next Tab         : a#inmain "Download the PDF" rect=16,139 132x21 tabindex=0

    Lines one and two are a pass: the link is the first stop and moves from left=-9999 into the viewport when focused. Line five is the failure. The page scrolled 921 px and document.activeElement is the body.

  3. Step 3.

    Run the same script against the router version, where a click handler cancels the fragment navigation and scrolls by hand.

    node skip-link.mjs http://localhost:8841/spa
    
    1st Tab, focused : a "Skip to content" rect=0,0 133x34 tabindex=0
    the same, blurred: rect=-9999,0 107x21 display=block
    href             : #main
    scrollY before   : 0
    after Enter      : a "Skip to content" rect=0,-955 133x34 tabindex=0
    scrollY after    : 955
    next Tab         : a "Products" rect=0,0 62x21 tabindex=0

    Focus is still on the link, whose rectangle reads top=-955, above the window. The next Tab lands on Products, the first item of the navigation.

  4. Step 4.

    Add tabindex="-1" to the target and run the same command against the fixed route.

    node skip-link.mjs http://localhost:8841/fixed
    
    …
    after Enter      : main#main "Quarterly reportRe" rect=0,0 1280x1232 tabindex=-1
    scrollY after    : 921
    next Tab         : a#inmain "Download the PDF" rect=16,139 132x21 tabindex=0

    One attribute on the target changes line five from (body) to the region itself. tabindex="-1" makes an element focusable by script and by fragment navigation without adding a tab stop.

  5. Step 5.

    Check the third failure mode, a link hidden with display: none until it is focused.

    node skip-link.mjs http://localhost:8841/gone
    
    1st Tab, focused : a "Products" rect=0,0 62x21 tabindex=0
    the same, blurred: rect=0,0 62x21 display=inline
    href             : /a
    the first tab stop is not a fragment link, stopping

    The skip link never appears, because display: none takes an element out of the tab order and :focus can never match it.

  6. Step 6.

    Point the script at a public site you did not build.

    node skip-link.mjs https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    1st Tab, focused : a "Skip to main conte" rect=2,2 1261x50 tabindex=0
    the same, blurred: rect=2,-320 1261x50 display=block
    href             : #content
    scrollY before   : 0
    after Enter      : (body)
    scrollY after    : 0
    next Tab         : a "application-layer" rect=375,275 145x20 tabindex=0

    Two parts pass and one fails, as on the demo. #content cannot take focus, so document.activeElement is the body after Enter.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | after Enter names the target element | Focus moved with the scroll | Nothing. This is the passing result | | after Enter : (body) | The page scrolled and focus went nowhere | Add tabindex="-1" to the fragment target | | after Enter names the link again | A handler cancelled the navigation and scrolled instead | Move focus in the handler, or drop the handler | | The blurred rectangle equals the focused one | The link is on screen at all times | Not a failure. Confirm the design intends that | | The first tab stop is a navigation link | The skip link is not focusable | Look for display: none or visibility: hidden on it |

Common mistakes

Sign: The audit presses Enter on the skip link, presses Tab once more, sees a control inside the content, and passes the page.Cause: Chrome moves the sequential focus navigation starting point to the fragment target even when that target cannot take focus. On the broken route the next Tab after Enter landed on the link inside main while document.activeElement was still the body. The tab sequence looks repaired and focus is on nothing, so the destination gets no focus ring and nothing is exposed to assistive technology. Read activeElement, not the next tab stop.
Sign: The skip link works in a plain page and stops working once the header moves into a router.Cause: A click handler that calls preventDefault and scrollIntoView replaces the fragment navigation with a scroll. On the /spa route focus stayed on the link, its rectangle read top=-955 after the scroll, so the indicator was above the viewport, and the next Tab returned to Products. Nothing in the markup changed.
Sign: The link is meant to appear on focus and never appears.Cause: display: none and visibility: hidden both remove an element from the tab order, so the :focus rule that would reveal it can never match. The working technique keeps the element rendered and moves it out of view, with position: absolute and a large negative left, then resets that on :focus.

What to check next

FAQ

What is a skip to content link?

A link at the top of the document whose target is the main content, so a keyboard user can bypass the header on every page. It satisfies WCAG 2.4.1 Bypass Blocks, Level A, and is usually kept off screen until it takes focus.

How to check a web page for an accessible skip link?

Three assertions on one walk, as step 2 runs them: the link is the first tab stop, its rectangle moves into the viewport when focused, and document.activeElement after Enter is the target. A check that stops at the first two passes a link that does nothing.

Why does the target need tabindex="-1"?

A main or div is not focusable by default, so following a fragment to it scrolls without moving focus. tabindex="-1" makes the element focusable by script and by fragment navigation, and adds no tab stop. Step 4 shows the one-attribute difference.

Does the skip link have to be the first tab stop?

It has to come before the block it bypasses. First is the usual position and the easiest to verify. A positive tabindex elsewhere pushes ahead of it, which the focus order check measures.

Can I test a skip link without a script?

Press Tab once, look for the link, press Enter, then press Tab again and watch where the ring appears. That finds the router failure in step 3. It misses the failure in step 2, where the tab sequence continues correctly and focus is on nothing.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny