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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/back-button.jsonPrompt
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.lengthat the moment of arrival. Comparing today'shistory.lengthagainst it answers "has this tab moved since it got here?", which is the questionhistory.length > 1only 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 watchdog —
history.back()has no callback and no return value, so a wrong verdict fails silently. Listening forpopstate/pagehideand giving up afterstallTimeoutturns 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 isaria-busy+aria-disabledrather than natively disabled, so focus stays where the visitor put it.