slop audit
Blog post 47 / Accessibility and interaction

Skip link: HTML, CSS, and keyboard testing guide

Build a skip link that bypasses repeated navigation, reveals itself on focus, reaches main content, and keeps working after route changes and deployment.

By Slop Audit · 7 min read

A skip link lets a keyboard user move past repeated navigation and reach the main content directly. Build it as a real link near the start of the document, make it visible when focused, and verify that activation moves focus to the intended target. A page scrolling downward is not enough: the next Tab should continue from the content rather than return to the header.

This guide covers the HTML and CSS, common production failures, and a focused Playwright test. Use it for a portfolio, documentation site, or application shell where the same header appears across routes.

Skip link checklist

Check a representative deployed route before release:

  1. The first page-level keyboard stop is a clearly named skip link.
  2. The link becomes readable and visible when it receives focus.
  3. Its fragment points to one existing target at the start of the unique content.
  4. Pressing Enter moves focus to that target.
  5. The next Tab reaches the first intended control in or after that content.
  6. Sticky headers and overlays do not cover the link or destination.
  7. The same behavior works after client-side navigation and at narrow widths.

Keep the keyboard accessibility testing checklist for the rest of the task. A working bypass does not prove that forms, menus, or dialogs are operable.

What a skip link does for bypass blocks

WCAG 2.2 Bypass Blocks requires a mechanism to bypass content repeated across multiple pages. A skip-to-main-content link is one technique; it is not the only way to meet that requirement. Headings and landmarks also support navigation through tools that expose those structures.

Consider a documentation page with a logo link, search control, six navigation links, and an account menu before the article. Without a bypass, a keyboard visitor repeats those stops on each page. A useful link takes the visitor to the article's start while leaving ordinary header navigation available when they need it.

The HTML landmarks guide covers the page's structural map. This article covers the direct keyboard action that reaches its main region. Test both rather than assuming a main element alone gives every keyboard user a convenient shortcut.

Build the skip link in HTML

Put the link before the repeated header controls and give its destination a stable, unique ID:

<body>
  <a class="skip-link" href="#main-content">Skip to main content</a>

  <header>
    <a href="/">Example Docs</a>
    <nav aria-label="Primary">
      <a href="/guides">Guides</a>
      <a href="/reference">Reference</a>
    </nav>
  </header>

  <main id="main-content" tabindex="-1">
    <h1>Install the SDK</h1>
    <p>Choose the setup instructions for your application.</p>
    <a href="/getting-started">Start the tutorial</a>
  </main>
</body>

The negative tabindex makes the region focusable without inserting it into the normal sequential Tab order. MDN's tabindex reference explains that distinction. Avoid a positive value to force the link first; fix the document order instead.

Choose a destination before the meaningful page content, not a footer, an empty spacer, or a hidden route container. If a framework shell already owns main, give that element the ID instead of adding a second main region inside the route component.

W3C's G1 skip-link technique tests whether the link is early, identifies its destination, becomes visible on focus, and moves focus to the main content. Retain the native href: it supplies link semantics and a useful fallback without waiting for a JavaScript handler.

Reveal the skip link with CSS

An always-visible link is valid. If your design hides it until keyboard focus, move it off screen while keeping it focusable, then reveal it with :focus:

.skip-link {
  position: fixed;
  inset-block-start: 0.75rem;
  inset-inline-start: 0.75rem;
  z-index: 1000;
  padding: 0.75rem 1rem;
  background: #fff;
  color: #111;
  border: 2px solid #111;
  transform: translateY(-200%);
}

.skip-link:focus {
  transform: translateY(0);
  outline: 3px solid #005fcc;
  outline-offset: 3px;
}

#main-content {
  scroll-margin-top: 6rem;
}

#main-content:focus {
  outline: 3px solid #005fcc;
  outline-offset: -3px;
}

Do not use display: none, visibility: hidden, or the hidden attribute for the resting link: it must remain reachable by keyboard. Inspect the revealed link against the actual page background and header. The sample z-index is a starting value for this layout; it does not guarantee visibility above your application's overlays.

Set the target's scroll offset for your real sticky header. MDN documents scroll-margin-top as an offset for the scroll margin area. Test the result at each header size instead of treating six rem as a universal value. Avoid adding a smooth-scroll animation to this essential shortcut unless you have tested the motion and focus behavior.

Fix a skip link that scrolls but leaves focus behind

Activate the link, then inspect document.activeElement in DevTools. If the page moves but focus stays on the link, inspect the destination's ID and focusability first. A changed hash does not demonstrate successful focus transfer.

Common failures have concrete causes:

  • No movement: the fragment does not match an existing ID, or the target mounts too late.
  • Wrong destination: duplicated IDs point the browser at an inactive route or old template.
  • Focus returns to navigation: the target was not focused, or another effect immediately moved focus away.
  • Invisible target: a sticky header covers the start, or the target sits inside a hidden view.
  • Link goes to another route: a document-level base URL or router changed how the relative fragment resolves.

If tested browsers still need explicit focus management, a small handler can focus the target while preserving normal fragment navigation:

const skipLink = document.querySelector(".skip-link");

skipLink?.addEventListener("click", () => {
  const target = document.getElementById("main-content");
  if (target instanceof HTMLElement) {
    target.focus({ preventScroll: true });
  }
});

This example uses the static HTML above. It deliberately leaves native navigation active. For a component framework, use its event binding and lifecycle cleanup, and resolve the current target at activation time. Do not install another document listener on every render.

A handler cannot repair an absent target. If the destination does not exist yet, fix the rendering contract or deliberately wait for the current route to mount before focusing it. Test that solution with delayed loading rather than adding an arbitrary timeout.

Keep the destination stable after route changes

A single-page application can retain the header while replacing the main region. Test the bypass after navigating to a second page through that header. A direct-load test can pass while a transition leaves duplicate targets or a handler pointing at a detached node.

Keep one active destination in the shared shell, or ensure each active route supplies the same unique ID. Verify that the current heading and content are inside it. Route-change focus management and skip-link activation are separate actions: an effect that always focuses a heading may race with the visitor's explicit bypass action.

Use the website navigation testing checklist for menus and destinations. Include the bypass in your shared-layout review whenever navigation, consent banners, or route containers change.

Add skip link testing to Playwright

For the sample page, test real keyboard discovery and activation rather than calling focus() on the link. Calling it directly would hide a regression where another control precedes the bypass.

import { expect, test } from "@playwright/test";

test("skip link bypasses the header", async ({ page }) => {
  // Replace the URL and expected next control with your route contract.
  await page.goto("https://example.com/guides/install");

  const skip = page.getByRole("link", { name: "Skip to main content", exact: true });
  const main = page.locator("main#main-content");
  await expect(skip).toHaveAttribute("href", "#main-content");
  await expect(main).toHaveCount(1);

  await page.keyboard.press("Tab");
  await expect(skip).toBeFocused();
  await expect(skip).toBeInViewport();

  await page.keyboard.press("Enter");
  await expect(main).toBeFocused();

  await page.keyboard.press("Tab");
  await expect(main.getByRole("link", { name: "Start the tutorial", exact: true }))
    .toBeFocused();
});

Playwright provides keyboard input and locator assertions for focus and viewport checks. The expected next control makes this test specific to the example. Adapt it to the first intended control on your route; some pages have no interactive content and continue to a control after main.

Run it in your supported browser projects, on desktop and narrow layouts, and add a separate case after client-side navigation. Do not weaken the first-stop assertion when an unexpected overlay appears: decide whether the overlay should take priority and test the bypass again after dismissal.

A viewport assertion does not prove the link is readable or uncovered. Keep a manual pass for contrast, clipping, sticky-header overlap, and the screen-reader announcement at the destination. Store the test in your website regression testing suite so shared-layout changes exercise it again.

Check the surrounding accessibility with Slop Audit

After testing activation, run Slop Audit on the deployed public URL. Its browser audit samples up to 12 forward Tab presses, records focus traversal and computed outline or shadow evidence, checks rendered navigation and main landmark counts, and runs axe for detectable accessibility violations.

Use those findings to locate surrounding keyboard or structural problems before launch. Slop Audit does not activate the skip link, verify its destination or next Tab stop, exercise every route, or certify WCAG conformance. Those checks remain in your manual pass and the focused test above; unavailable browser evidence is a coverage gap.

For release sign-off, save the route, build identifier, browser, viewport, and observed next control. A useful result says that the visitor reached the current content and continued from it, with the link and destination visible under the tested layout.