Skip link
Skip link — "Skip to content", the first focusable element on the page.
Examples
How it looks once it has focus
Skip to content<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.
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.
| Token | Default |
|---|---|
| --skip-link-inset | var(--space-3) |
| --skip-link-z-index | 200 |
| --skip-links-gap | var(--space-2) |
| --skip-links-inset | var(--space-3) |
| --skip-links-z-index | 200 |
| --skip-link-bg | var(--color-primary) |
| --skip-link-border-color | var(--color-primary) |
| --skip-link-border-width | var(--border-width) |
| --skip-link-color | var(--color-on-primary) |
| --skip-link-font-family | var(--font-secondary) |
| --skip-link-font-size | var(--font-size-sm) |
| --skip-link-font-weight | var(--font-weight-medium) |
| --skip-link-padding-block | var(--space-3) |
| --skip-link-padding-inline | var(--space-4) |
| --skip-link-radius | var(--radius-field) |
| --skip-link-shadow | var(--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
- 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. - 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.activeElementstays<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. - Do not nest it in a transformed ancestor.
transform,filterandbackdrop-filteron an ancestor make it the containing block forposition: fixed, and the link would then hide inside that element rather than off the viewport. As a direct child of<body>this cannot happen. - Set
--scroll-paddingif the sticky header is taller than 6.25rem. That is all a sticky header needs.base/reset.cssalready putsscroll-padding-block-start: var(--scroll-padding)onhtml, and it covers the focus scroll as well as the fragment jump: measured, the target lands exactly--scroll-paddingbelow the viewport edge in all three engines. This component deliberately ships no.skip-link-targethelper. An earlier draft did, addingscroll-margin-block-startto 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