Navigation

Back Button

A back control that traverses history when there is an in-app page behind it and follows a real href when the visitor arrived cold — so a deep link never dead-ends.

Preview in your theme

Loading preview…

"use client"

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

/** Which of the two destinations a press would take. */
export type BackButtonRoute = "history" | "fallback"

export interface BackButtonLabelState {
  /** The route a press would take right now. */
  route: BackButtonRoute
  /** Name of that route's destination, when the consumer supplied one. */
  destination: string | null

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/back-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BackButton" component: the back
affordance that survives a deep link. lucide-react for the arrow; no router and
no navigation library — it talks to window.history directly and accepts the
consumer's router as a prop when there is one.

Contract
- forwardRef<HTMLAnchorElement>. It renders an <a> in every state, so the ref and
  every native anchor prop not listed below land on that element.
- Props extend Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> and add:
  href: string (required — the fallback destination, and always the element's
  real href), fallbackLabel?: string | null, backLabel?: string | null,
  label?: string (default "Back"), showLabel?: boolean (default true),
  formatLabel?: (state: BackButtonLabelState) => string,
  variant?: "ghost" | "outline" | "solid" (default "ghost"),
  size?: "sm" | "md" (default "md"), icon?: React.ReactNode,
  canGoBack?: boolean, storageKey?: string (default "zyeon.back-button.arrival"),
  stallTimeout?: number in ms (default 1200),
  onNavigate?: (route, href) => boolean | void,
  navigate?: (href: string) => void,
  onRouteChange?: (route) => void.
- export type BackButtonRoute = "history" | "fallback" — the two destinations,
  and the value of the data-route attribute the element always carries.
- formatLabel receives { route, destination, label, href }, where destination is
  backLabel on the history route, fallbackLabel on the fallback route, and null
  when the consumer named neither. Default: `${label} to ${destination}`, or the
  bare label when there is no destination name.

Behavior
- The verdict — "is one of OUR pages behind this tab?" — weighs three signals,
  strongest first:
  1. window.navigation.canGoBack. It is exact: the Navigation API's entry list
     holds only the same-origin contiguous entries, so "can go back" already
     means "back to us".
  2. history.length <= 1. A tab with a single entry cannot go back at all — this
     is the deep link opened in a new tab, the case the component exists for.
  3. Did this tab move since it arrived? Compare history.length with a number
     written once per tab into sessionStorage on first mount, and OR it with a
     same-origin document.referrer, which covers the multi-page app where the
     marker was written on the very page we are standing on.
- Read that through useSyncExternalStore — never during render, never with a
  setState inside an effect. Subscribe to popstate (same-document traversal),
  pageshow (a bfcache restore lands you elsewhere in the entry list) and the
  Navigation API's currententrychange (the only event that reports a pushState).
  The snapshot is the route string, a stable primitive; the server snapshot is
  "fallback", so server and hydrating client emit identical markup.
- Only browsers without an exact answer get the sessionStorage write, and every
  storage access sits in try/catch — private mode and sandboxed iframes throw on
  the property access itself, not just on the write.
- canGoBack, when passed, replaces the probe entirely: no listeners, no storage,
  and the server snapshot honours it, so a router-aware app can server-render the
  final markup.
- A stale verdict is safe by construction: it degrades towards "fallback", which
  is a real destination, and that is why the component needs no re-probe prop —
  the escape hatch for a router that knows better is canGoBack.
- Click: call the consumer's onClick first, bail if it preventDefault()ed. Bail
  on event.button !== 0 and on meta/ctrl/shift/alt so a modified press keeps its
  native meaning: ⌘-click and middle-click open the fallback in a new tab, which
  is exactly right there, because a new tab has no history to traverse.
- One press, one navigation. An inFlight ref is read AND written synchronously
  inside the handler; two clicks in one tick would otherwise both pass, and two
  history.back() calls jump two pages back. The refused press only
  preventDefault()s — it is silent, not an error.
- onNavigate then fires with the route about to be taken. Returning false means
  "I handled it": preventDefault, disarm, do nothing else.
- History route: preventDefault(), then history.back(). Fallback route:
  navigate(href) when the consumer supplied a router, otherwise let the browser
  follow the real href — no preventDefault at all.
- Stall watchdog, which is what makes "never a dead end" true even when the
  verdict was wrong: after dispatching, listen for popstate and pagehide (the two
  ways a browser admits it really left) and start a stallTimeout timer. Whichever
  comes first releases the guard; the timer additionally follows the fallback
  href, because a back() that never landed had nowhere to go. stallTimeout <= 0
  keeps the guard and drops the rescue.
- While a press is in flight the element is aria-busy + aria-disabled and the
  guard lives in the handler — never the native disabled attribute, which blurs
  the element the visitor is standing on. For the same reason the element never
  swaps between <a> and <button> when the verdict changes: it is one anchor whose
  handler behaves differently, so a verdict arriving at hydration cannot move
  focus to <body>.
- Cleanup: the timer and both window listeners are torn down on unmount, and
  before a new press arms its own.
- Keyboard: this is a link, so Enter activates and Space scrolls the page — on
  purpose, because the href is real and link semantics are the truth here. One
  tab stop, never removed from the tab order (not even while busy), visible
  focus ring.
- ARIA: no role override. aria-label carries the composed sentence only in
  icon-only mode, so a visible label and the accessible name can never disagree.
  data-route="history" | "fallback" is exposed for styling and for tests. The
  arrow is aria-hidden.
- onRouteChange reports the verdict on mount and on every change — that is how
  you measure what share of visitors arrive cold.

Rendering & styling
- Semantic tokens only: ghost = text-muted-foreground with hover:bg-accent /
  hover:text-accent-foreground; outline = border + bg-background; solid =
  bg-primary / text-primary-foreground with hover:bg-primary/90. Busy is
  aria-disabled:opacity-70 + aria-disabled:cursor-progress. Focus is
  focus-visible:ring-2 ring-ring with ring-offset-background.
- cn() merges every className, and the consumer's classes win on conflicts.
- Motion is decoration: the arrow nudges 2px on hover under
  motion-safe:group-hover, so with reduced motion nothing moves and the control
  behaves identically.
- rtl:rotate-180 on the arrow — "back" points the other way in an RTL document.
- The label truncates rather than wrapping; the control stays one line high.

Customization levers
- Density: WITH_LABEL is h-8/px-2.5/text-xs and h-9/px-3/text-sm, ICON_ONLY is
  size-8 / size-9. Nothing is measured in JS, so new sizes are a map entry.
- Variants: the three token recipes are a plain record — add "link" (underline,
  no padding) or "onPrimary" (inverted for a coloured header) without touching
  the state machine.
- Sub-blocks: drop the text with showLabel={false} for a toolbar, or drop the
  icon by passing icon={<span />}; formatLabel owns the whole sentence, so
  "Zurück zu Projekten" or "← Projects" are one function away.
- Router integration: navigate={href => router.push(href)} turns the fallback
  into a client-side transition; onNavigate is the analytics / interception seam;
  canGoBack lets a router that tracks its own stack override the probe.
- Safety dials: stallTimeout is the patience for a back() that may never land —
  raise it for slow cross-document restores, set it to 0 to trust the verdict
  absolutely. storageKey namespaces the arrival marker when two apps share an
  origin.
- Tokens: recolour the busy state or point the focus ring at var(--chart-1) if
  the control sits on a chart surface; nothing here is a hex value.

Concepts

  • Two destinations, one element — the control is an <a href> whose href is always the fallback, and the history route is a behaviour layered on top of it. That is why middle-click, ⌘-click and a click that beats hydration all work, and why the verdict changing after hydration cannot swap the element under the visitor's focus.
  • Arrival marker — one number in sessionStorage, written the first time this tab renders the control: history.length at the moment of arrival. Comparing today's history.length against it answers "has this tab moved since it got here?", which is the question history.length > 1 only pretends to answer.
  • Cold arrival — the visitor who came from a shared link, a push notification or a fresh tab. They have no history, so "Back" is a dead key for them unless the control carries its own destination — and they are the majority on any page that gets shared.
  • Stall watchdoghistory.back() has no callback and no return value, so a wrong verdict fails silently. Listening for popstate / pagehide and giving up after stallTimeout turns that silence into the fallback navigation.
  • One press, one navigation — the guard is a ref read and written in the same synchronous handler, because two clicks in one tick both see stale state and two back() calls jump two pages. While it holds, the control is aria-busy + aria-disabled rather than natively disabled, so focus stays where the visitor put it.

On This Page