Skip Link
A hidden-until-focused link cluster that moves focus — not just the scroll — to a page landmark, clearing a sticky header by a configurable offset.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/skip-link.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SkipLink" component. No animation library and
no UI dependencies: one nav landmark, a list of anchors, and a live region.
Contract
- Export a forwardRef <nav> extending Omit<React.HTMLAttributes<HTMLElement>, "children">.
- targets: { id: string; label: string }[] — required, rendered in tab order. An empty
array renders null; a link with nowhere to go still costs a tab stop.
- offset?: number (default 0) — pixels kept clear above the landing spot, i.e. the
height of your sticky header. It moves the target, never the panel.
- scrollBehavior?: ScrollBehavior (default "smooth").
- containerRef?: React.RefObject<HTMLElement | null> — the scroll container holding the
targets; omit to scroll the page.
- position?: "fixed" | "absolute" (default "fixed"), label?: string (default
"Skip links") for the nav's aria-label.
- onSkip?: (id: string) => void fires only after focus really landed;
onSkipError?: (id: string, reason: "missing" | "unfocusable") => void when it did not.
- The only React state is the current refusal message. Visibility is CSS.
Behavior
- Reveal: the panel is visually hidden (clipped to 1px, never display:none, so it keeps
its tab stop and its screen-reader text) and shown by a plain :focus-within rule. It
therefore works before hydration, and it hides itself the instant focus leaves —
including the instant a jump moves focus onto the target.
- Keyboard map: Tab from the top of the page reveals the panel and focuses the first
link; Tab / Shift+Tab walk the list; Enter activates (native anchor behaviour);
Shift+Tab off the first link or Tab off the last hides it again. No Escape handler and
no arrow keys — this is a list of links, not a menu — and Space keeps its native
page-scroll meaning.
- Activation: bail out and let the browser do its native thing when the click carries
meta / ctrl / shift / alt (open in a new tab), otherwise preventDefault. The href stays
a real "#id" for no-JS and middle-click, but the URL hash stays the consumer's
business: a router must not see a navigation nobody asked for.
- Focus, not scroll — the whole point. Look the element up with
document.getElementById(id). If it declares no tabindex, set tabindex="-1" and remove
it again on that element's blur, so the page never keeps an attribute it did not
author; leave an authored tabindex alone. Then call focus({ preventScroll: true }): a
plain focus() slams the target against the top edge of every ancestor scroller and
ignores the offset.
- Landing: scroll by (target top within the scroller) - offset, where "top within" is the
element's bounding rect top minus the scroller's rect top minus its clientTop (the
scroller's own border), or plain viewport coordinates when there is no scroller. Under
prefers-reduced-motion the behaviour is forced to "auto" whatever the prop says: the
jump is the feature, the animation is not.
- Landing verification: a sticky header is not a constant — it shrinks on scroll, un-pins,
or finishes loading a webfont while the smooth scroll is still running, and the target
drifts back underneath it. Poll with requestAnimationFrame until the scroll position
holds still for two frames (with a ~350ms minimum wait for smooth scrolls, so a scroll
that has not started yet is not mistaken for one that finished) or ~1.2s elapses, then
re-measure and make exactly ONE instant correction when the drift exceeds 1px. Never
loop, and skip the correction when document.activeElement is no longer the target — the
reader moved on and must not be yanked.
- Edge cases are first-class, not afterthoughts: id absent from the document -> refuse
with reason "missing"; element present but display:none / inert / detached, detected by
document.activeElement !== el right after focus() -> hand the borrowed tabindex back and
refuse with "unfocusable"; containerRef passed but the target lives outside it -> scroll
the page instead; targets empty -> render nothing.
- Refusals are loud. The message goes into a role="status" aria-live region and stays
visible inside the panel — focus never left the link, so the panel is still open — and
onSkipError fires. Clear the message on a timer, otherwise an identical second refusal
produces no DOM change and is never re-announced.
- Cleanup: on unmount clear the message timer, cancel the pending rAF, and run the pending
tabindex release. Re-activating any link first cancels the previous landing check and
releases the previous target. Every guard reads and writes refs synchronously inside the
handler, so a double press can never leave two checks running.
Rendering & styling
- Semantic tokens only: panel bg-popover / text-popover-foreground with border,
rounded-lg, shadow-lg, z-50; links bg-primary / text-primary-foreground with
hover:bg-primary/90; the refusal line text-destructive; focus-visible:ring-2 ring-ring
with ring-offset-2 ring-offset-popover so the ring reads against the panel.
- Hidden state is the sr-only recipe written out: absolute, size 1px, -1px margin,
overflow hidden, clip rect(0,0,0,0), pointer-events none, zero border and padding. Every
revealed rule is a focus-within: variant, which outranks the base utility on specificity
— so class order never decides the outcome.
- The panel sits at top-4 left-4 with z-50, deliberately ABOVE the sticky header rather
than below it. Only the target respects offset.
- Markup: nav[aria-label] > ul > li > a[href="#id"]. The visible label is the accessible
name — no icon-only variant, no aria-label overriding the text. The live region is a
<p role="status" aria-atomic> that goes sr-only while empty, so it adds no gap to the
flex column and never becomes display:none.
- Motion: the only transition is the links' hover colour, with motion-reduce:transition-none.
Merge the consumer className with cn().
Customization levers
- Placement: top-4 left-4 is the western default; centre it with left-1/2 plus a translate,
or mirror to right-4 for RTL. Raise z-50 only if your own header outranks it.
- offset is the sticky-header lever: pass the header height (or a number read once from a
CSS variable). It changes where the reader lands, never where the panel draws.
- Targets: one ("Skip to main content") is the minimum that satisfies WCAG 2.4.1; add
navigation / search / footer only when those regions are genuinely long — every extra
link is a tab stop for everyone.
- Surface: swap bg-popover for bg-background, or drop the panel chrome entirely and keep a
single bg-primary pill when you ship only one target.
- Landing spot: style [tabindex="-1"]:focus on your own regions (a ring, a highlight) —
the component moves focus there, you decide what arriving looks like.
- position="absolute" is for embedded shells and previews, where an ancestor transform
would break position:fixed anyway; "fixed" is the page default.Concepts
- Focus, not scroll — a bare
#anchormoves the viewport and leaves the tab sequence untouched, so the next Tab walks straight back into the navigation the reader just escaped. Moving focus is what makes the escape stick. - Borrowed tabindex — landmarks are not focusable, so the component lends the target a
tabindex="-1"and takes it back on blur. Nothing in your markup changes, and an element that already declares a tabindex is left exactly as it was. - Reveal is CSS, not state —
:focus-withinshows the panel, so it appears on the very first Tab of a page that has not hydrated yet, and it disappears by itself the moment the jump carries focus away. - Offset versus z-index — the panel is drawn over the sticky header (z-50), while the landing spot is pushed below it (offset). Confusing the two produces the classic bug: a skip link hidden under the header it is meant to escape.
- Landing verification — headers shrink, un-pin and reflow mid-scroll. After the scroll settles the component measures once more and makes a single instant correction, rather than trusting the arithmetic it did before the page moved.
- Refusal beats silence — a stale id or a hidden region makes the link announce why nothing happened and report it to
onSkipError, instead of leaving a keyboard user pressing Enter at a dead control.