Retro Back Link

A single "go back" anchor styled for retro / terminal chrome. Renders either as a bordered pill (the default — drops cleanly into a header rail or a top breadcrumb) or as a plain inline link (slots into body copy). On pointer enter or keyboard focus the visible label can swap to an alternate hover affordance via a stacked opacity crossfade, while the accessible name stays stable. The whole thing slides in from the left on first paint with a spring.

Customize
Layout
pill
Slots

Installation

npx shadcn@latest add https://craftbits.dev/r/retro-back-link.json

Usage

import { RetroBackLink } from "@craft-bits/core";
 
<RetroBackLink href="/archive" label="Back" />

Swap the label on hover / focus without changing the announced name:

<RetroBackLink
  href="/archive"
  label="Back"
  hoverLabel="Back to archive"
/>

Use the inline variant for in-prose links:

<RetroBackLink variant="inline" href="/archive" label="back to archive" />

Pass a custom glyph for the caret or hide it entirely with caret={null}. Drop a small badge, SVG, or counter into the trailing decor slot.

Understanding the component

  1. Anchor-first. Renders a real anchor element so right-click, middle-click, and keyboard activation all work without router coupling.
  2. Caret is its own slot. The leading glyph defaults to a left arrow and is rendered in its own aria-hidden span. caret={null} hides it; any node replaces it.
  3. Hover-label stacks underneath. Instead of mutating the DOM, the resting label and hoverLabel are stacked absolutely inside a relative wrapper. Opacity crossfades on :hover and :focus-visible swap them. aria-label keeps the accessible name pinned to the resting label so assistive tech does not see the change.
  4. Spring slide-in. First paint slides from x: -6 to 0 with the SPRINGS.smooth token. prefers-reduced-motion: reduce and the disableMotion prop both short-circuit to a static wrapper.
  5. Two visual variants. pill (default) draws a bordered pill on the elevated surface and pairs with header rails. inline strips the border and surface, leaving a plain text link for body copy.
  6. Trailing decor slot. Renders after the label inside the same anchor, separated by the default gap. Hidden from assistive tech so the announced name stays the label alone.
  7. Focus and hover share state. The opacity crossfade fires on both :hover and :focus-visible, so keyboard-only users see the same affordance without a pointer.
  8. Data hooks. The anchor carries data-cb-retro-back-link and data-variant; the caret, label, and decor each carry a matching attribute for consumer CSS targeting.

Variants

Pill (default)

<RetroBackLink href="/archive" label="Back" />

Inline

<RetroBackLink variant="inline" href="/archive" label="back to archive" />

With hover-label swap

<RetroBackLink
  href="/archive"
  label="Back"
  hoverLabel="Back to archive"
/>

Without caret

<RetroBackLink href="/archive" caret={null} label="Back" />

Props

PropTypeDefaultDescription
hrefstring'/'Destination URL or app-relative path.
labelReactNode'Back'Resting label content. String labels are used as the accessible name.
hoverLabelReactNodeWhen set, crossfaded in on pointer enter / focus and out on leave / blur.
caretReactNodeleft arrowLeading glyph. Pass null to hide.
decorReactNodeTrailing decorative slot rendered after the label. Hidden from assistive tech.
variant'pill' | 'inline''pill'Visual style. pill is bordered; inline is a plain text link.
disableMotionbooleanfalseSkip the slide-in mount animation.
classNamestringMerged onto the anchor via cn().
...restAnchorHTMLAttributes<HTMLAnchorElement>Any other anchor attribute.

Accessibility

  • Renders a real anchor so default link semantics, keyboard activation, and right-click context menus all work.
  • A :focus-visible ring tied to the accent token gives keyboard-only users a clear focus indicator across both variants.
  • The aria-label is set to the string form of label when available, so the announced name stays stable even while the visible text crossfades to hoverLabel.
  • The hover-label crossfade fires on :focus-visible, so keyboard users see the longer affordance without a pointer.
  • The caret, hoverLabel, and decor slots are aria-hidden="true" — assistive tech reads only the resting label.
  • The spring slide-in respects prefers-reduced-motion: reduce and the disableMotion prop, both short-circuiting to a static wrapper.
  • Color contrast on --cb-fg-muted and --cb-accent meets WCAG AA against both --cb-bg and --cb-bg-elevated.

Credits

  • Extracted from: terminal-dreams (src/components/retro/RetroBackLink.tsx). The original was a next/link-bound component with two hard-coded variants, inline-style borders, a hard-coded Back / Back to archive swap that mutated textContent on mouse events, and a hand-rolled usePrefersReducedMotion() hook. craft-bits keeps the variant matrix, the slide-in, and the hover-swap behaviour, but generalises every piece: a plain anchor instead of next/link so consumers wire their own router, CVA-driven Tailwind classes instead of inline styles, configurable label / hoverLabel / caret / decor props instead of hard-coded strings, a stacked opacity crossfade instead of DOM mutation so the accessible name stays stable, focus-aware swap so keyboard users get the same affordance, and useReducedMotion() from motion/react for the reduced-motion fallback.