Skip link

a.skip-link

Skip link — "Skip to content", the first focusable element on the page.

Examples

The first focusable element

View “The first focusable element” as

How it looks once it has focus

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

<main class="skip-link-target" id="content" tabindex="-1">
  <h1>Quarterly report</h1>
</main>

The stage shows the link in the state you will never catch it in. A skip link is off screen until it is focused, so there would otherwise be nothing in the stage at all.

What you see is the real component, held in place by this page; the markup under the Code tab is unchanged and contains no such wrapper. The only complete demonstration is still to press Tab from the very top of this page, because the link at the top of this site is this component. tabindex="-1" on the target is not optional — without it the page scrolls but focus stays put, and the next Tab resumes from the header the user was escaping. The other thing to know is a defect this library shipped once: an element that hides itself off-viewport has to clear whatever it paints outside its box. --elevation-3 is an 8px offset under a 16px blur, and 8px of clearance left the link’s shadow on screen while its box was gone.

Several destinations

View “Several destinations” as

All three, as they appear when focused in turn

<div class="skip-links">
  <a class="skip-link" href="#content">Skip to content</a>
  <a class="skip-link" href="#site-nav">Skip to navigation</a>
  <a class="skip-link" href="#site-search">Skip to search</a>
</div>

Each target needs its own tabindex="-1". More than three is worse than one — a keyboard user has to Tab past every link to reach the page, which is the cost the component exists to remove.

Tokens 16

Level 2, declared on .skip-link itself. Set any of them on that selector to restyle this component without touching the skin.

Level 2 tokens declared by skip-link
TokenDefault
--skip-link-insetvar(--space-3)
--skip-link-z-index200
--skip-links-gapvar(--space-2)
--skip-links-insetvar(--space-3)
--skip-links-z-index200
--skip-link-bgvar(--color-primary)
--skip-link-border-colorvar(--color-primary)
--skip-link-border-widthvar(--border-width)
--skip-link-colorvar(--color-on-primary)
--skip-link-font-familyvar(--font-secondary)
--skip-link-font-sizevar(--font-size-sm)
--skip-link-font-weightvar(--font-weight-medium)
--skip-link-padding-blockvar(--space-3)
--skip-link-padding-inlinevar(--space-4)
--skip-link-radiusvar(--radius-field)
--skip-link-shadowvar(--elevation-3)

Variants and states

Variants

None. It is one shape, and the page does the rest.

Inside it

  • .skip-links

State it reads

Read from the platform, never mirrored into a class that could disagree with it.

  • :active
  • :focus
  • :hover

Before you ship it

What you have to do 4 requirements

  1. Put it first. It must be the first focusable element in <body>, before the header, or it is not a skip link. Source order is the tab order.
  2. Put tabindex="-1" on the target. <main id="content" tabindex="-1"> <main id="content"> alone is not focusable. Measured 2026-08-08 in Chromium, Firefox and WebKit, what breaks without the attribute is more specific than the folklore says, and worth knowing precisely: - The tab sequence moves anyway. All three engines set the sequential focus navigation starting point from the fragment, so the next Tab does land after the target. Testing with Tab alone therefore shows no bug. - document.activeElement stays <body> in all three. Focus never reaches the region, so a screen reader's cursor does not move and nothing is announced — the user hears the link activate and then silence, with no evidence they went anywhere. So the attribute is not what makes the jump work; it is what makes the jump perceivable, which for this component is the entire point. Nothing in CSS can supply it and nothing detects its absence at runtime.
  3. Do not nest it in a transformed ancestor. transform, filter and backdrop-filter on an ancestor make it the containing block for position: fixed, and the link would then hide inside that element rather than off the viewport. As a direct child of <body> this cannot happen.
  4. Set --scroll-padding if the sticky header is taller than 6.25rem. That is all a sticky header needs. base/reset.css already puts scroll-padding-block-start: var(--scroll-padding) on html, and it covers the focus scroll as well as the fragment jump: measured, the target lands exactly --scroll-padding below the viewport edge in all three engines. This component deliberately ships no .skip-link-target helper. An earlier draft did, adding scroll-margin-block-start to the destination, and the two compound: the scroller's padding and the target's margin are both applied, so a 100px allowance became a 200px gap and the heading the user jumped to sat halfway down the screen. One offset, in one place.

src/css/components/skip-link.css · npx mostlycss add skip-link