Navigation

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** One destination the reader can jump to. */
export interface SkipLinkTarget {
  /** id of an element that already exists in the page — no other markup change required. */
  id: string
  /** Link text, and therefore the accessible name. Say where it goes: "Skip to main content". */
  label: string
}

/** Why a jump refused: the id is not in the document, or the element could not take focus. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/skip-link.json

Prompt

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 #anchor moves 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-within shows 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.

On This Page