Skip to content

Component

skip-link

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and skip-link.css. No scripts.

Source

Copy both files, or run npx nojsui add skip-link.

Source for skip-link
<!-- First element in the body, and its href points at the main content's id.
     Both matter: any later in the source and it is not the first tab stop; a
     missing target and it goes nowhere.

     tabindex="-1" on the target is the part almost every hand-rolled skip link
     misses. Without it the browser scrolls to the target but focus stays where
     it was, so the next Tab continues from the link rather than from the
     content — which is the whole thing the user asked for.

     Press Tab to see it. -->
<a class="sk-skip-link" href="#sk-skip-link-main">Skip to content</a>

<main class="sk-skip-link__page" id="sk-skip-link-main" tabindex="-1">
  <h3>Main content</h3>
  <p class="sk-skip-link__text">
    The link above is the first thing Tab reaches. It is not painted until it
    is focused — clipped rather than moved off-screen, so it keeps its box and
    stays in the tab order.
  </p>
  <p class="sk-skip-link__text">
    It sits above the app bar's stacking order, because a skip link that
    appears behind the site header is invisible in the one moment it matters.
  </p>
</main>
/* .sk-skip-link — the first tab stop, hidden until it is focused.

   THIS COMPONENT IS ENTIRELY ABOUT GETTING THE HIDING RIGHT, and there are
   three ways to do it of which two are wrong:

     display: none / visibility: hidden — removes it from the tab order AND
       from the accessibility tree. The link is in the markup, looks done on a
       checklist, and reaches nobody: not a keyboard user, not a screen-reader
       user. Worse than shipping no skip link at all, because it looks shipped.
     a transform off-screen — works, but leaves the element displaced while the
       page is at rest, which is indistinguishable from content stranded by a
       feature the engine lacks. This kit's conformance suite reports exactly
       that, and it is right to.
     clip-path: inset(50%) — the box stays where it is, at full opacity, in the
       tab order, and simply is not painted. Baseline widely available, so
       nothing is hidden here by a feature that might be missing.

   AND WHY THIS FILE WRITES A :focus-visible RULE:
   base.css owns the focus RING and this file does not touch it. What is styled
   here is the REVEAL. The entire point of the component is that focus makes it
   appear, so the focus state is not decoration on top of the component — it is
   the component.

   It is not alone in this. A component writes :focus-visible when focus lands
   on one element and the effect belongs on another — tabs and command-menu
   forward a visually-hidden radio's ring onto its label — or when focus
   reveals a sibling, which tooltip does. This is the third case: focus
   reveals the element itself.

   Specs:
   - clip-path ............. https://drafts.fxtf.org/css-masking-1/#the-clip-path
   - :focus-visible ........ https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-skip-link {
  --_bg: var(--sk-skip-link-bg, var(--sk-color-surface-raised));
  --_fg: var(--sk-skip-link-fg, var(--sk-color-text));
  --_radius: var(--sk-skip-link-radius, var(--sk-radius-md));

  /* No visible edge by default: the kit's own look already separates the
     chip from the page with radius and shadow, so it needs no border. A
     theme that zeroes --sk-radius-* and --sk-shadow-* (flat, cards-are-
     hairlines) can lose that separation entirely — if its surface-raised
     also happens to equal its page background, the reveal paints as text
     with nothing behind it. This knob is the edge such a theme has to reach
     for. The fallback chains to --_bg, not to the literal `transparent`
     keyword: the theme contract requires a colour fallback to reach a
     token, and matching the chip's own background paints exactly as
     invisible while still resolving through --sk-color-surface-raised. */
  --_border: var(--sk-skip-link-border-color, var(--_bg));

  /* Above --sk-app-bar-z, which is 100. A skip link that appears behind the
     site header is invisible in the one moment it matters. */
  --_z: var(--sk-skip-link-z, 200);

  position: fixed;
  inset-block-start: var(--sk-space-xs);
  inset-inline-start: var(--sk-space-xs);
  z-index: var(--_z);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  box-shadow: var(--sk-shadow-md);
  font-size: var(--sk-text-sm);
  text-decoration: none;

  /* Not painted, but still laid out and still focusable. */
  clip-path: inset(50%);
}

.sk-skip-link:focus-visible {
  clip-path: none;
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour — the demo needs something to skip to.
--------------------------------------------------------------------------- */

.sk-skip-link__page {
  display: grid;
  gap: var(--sk-space-sm);
  padding: var(--sk-space-lg);
}

.sk-skip-link__text {
  margin: 0;
  color: var(--sk-color-text-muted);
  font-size: var(--sk-text-sm);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS

Usage

Place the link as the first element inside <body> — before the app bar, before any wrapper — and point its href at the id of the page’s main content. Both matter: put it later in the source and it is no longer the first tab stop; give it no matching target and pressing it does nothing.

<a class="sk-skip-link" href="#main">Skip to content</a>
<!-- ... app bar, nav, everything else ... -->
<main id="main" tabindex="-1">…</main>

How it works

display: none and visibility: hidden both remove the link from the tab order and from the accessibility tree, so it is in the markup, looks done on a checklist, and no keyboard or screen-reader user can ever reach it. Moving it off-screen with a transform keeps it reachable, but leaves it displaced while the page is at rest, which is exactly what this repo’s own conformance check reports as content stranded by a missing feature. It is right to flag that.

So this component hides with clip-path: inset(50%) instead: the box keeps its position, its opacity, its place in the tab order — it simply is not painted. clip-path is Baseline widely available, so nothing here depends on a feature that might be absent.

Why this component writes a :focus-visible rule. base.css owns the kit’s single focus ring, and that rule still holds here — this file never touches the ring. What it styles instead is the reveal: the link’s entire reason to exist is that focus makes it appear, so the focus state is not decoration layered on top of the component, it is the component.

It is not the only component that writes one, and the earlier version of this file wrongly said it was. A component writes :focus-visible when focus lands on one element and the effect belongs on another — tabs and command-menu forward a visually-hidden radio’s ring onto its visible label — or when focus reveals a sibling, which tooltip does. This is the third case: focus reveals the element itself.

Following the link moves the browser’s navigation position to the target. For the target to actually receive focus — rather than the page merely scrolling to it, leaving focus behind on the link — it needs tabindex="-1". That is the detail almost every hand-rolled skip link misses: without it, the next Tab press continues from the skip link itself instead of from the content, which defeats the point.

Keyboard contract

This is the one component whose contract must be verified by keyboard rather than by inspection: a screenshot of the page at rest cannot show whether the link reveals on focus.

KeyBehaviour
Tab (from page load)Moves focus to the skip link — it is the first tab stop
EnterActivates the link, moving focus and scroll to the main content

Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done, and change this line when you have.

Accessibility notes

  • The link must be the first focusable element in <body> — its value comes entirely from being reachable before anything else on the page.
  • Because it is clipped rather than removed, screen readers announce it in document order like any other link; nothing about its hiding technique hides it from assistive technology.
  • The target needs tabindex="-1" so focus actually lands on it — see “How it works” above. Without it, the destination is only scrolled to, not focused, and the next Tab continues from the skip link.

Theming

PropertyDefaultControls
--sk-skip-link-bg--sk-color-surface-raisedBackground once focused
--sk-skip-link-fg--sk-color-textText colour
--sk-skip-link-radius--sk-radius-mdCorner radius
--sk-skip-link-border-color--sk-skip-link-bg (matches the chip, so invisible)Border colour. Invisible by default; a theme that zeroes radius and shadow can set this to keep the revealed chip visible
--sk-skip-link-z200Stacking order. Must stay above --sk-app-bar-z (100)

Degradation

Feature usedBaseline statusBehavior without it
clip-pathWidely availableNothing degrades — every supported browser paints the reveal correctly