Scroll to Top
A floating button that fades in past a scroll threshold and smoothly scrolls its container back to the top.
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils, use-prefers-reduced-motion
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-to-top.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScrollToTop" component using lucide-react.
Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — getPrefersReducedMotion() is read once per click to
pick the scroll behavior. If rebuilding without it: read
matchMedia("(prefers-reduced-motion: reduce)").matches at the moment of the
click.
- Export a forwardRef button extending React.ButtonHTMLAttributes<HTMLButtonElement>.
- threshold?: number — scroll distance in px past which the button becomes
visible (default 400).
- target?: React.RefObject<HTMLElement | null> — the scroll container to
observe; omit to track window/document scroll instead.
- position?: "bottom-right" | "bottom-left" — which fixed corner the button
docks to (default "bottom-right").
- focusTarget?: React.RefObject<HTMLElement | null> — the element that gets
keyboard focus after the scroll (e.g. <main tabIndex={-1}> or the first
heading). It must already be focusable; the component does not add a
tabindex to it.
- className and the rest of the native button props pass through; the
component's own onClick runs first (it performs the scroll), then the
consumer's onClick fires.
Behavior
- Attach a passive scroll listener to target.current ?? window. Throttle with
requestAnimationFrame: a scroll event schedules at most one rAF tick, which
reads the current scrollTop (or window.scrollY) and sets a boolean
"visible" state — this is the one piece of state in the component, and it
only ever changes at a low frequency (crossing the threshold), so it's fine
for it to live in React state instead of being written straight to the DOM.
- Run the same check once on mount, in case the container is already scrolled
past the threshold when it appears.
- On click: scrollTo({ top: 0, behavior }) on the target (or window),
where behavior is "auto" if prefers-reduced-motion matches, else "smooth".
Then, if focusTarget is set, focus it with { preventScroll: true } so a
keyboard user continues from the top of the content instead of from the
corner button (preventScroll keeps the focus call from jumping the
viewport or cutting the smooth scroll short).
- Clean up both the scroll listener and any pending rAF on unmount or when
target/threshold change.
- The button always stays mounted (so the opacity/translate transition can
play both ways); when hidden it gets pointer-events-none, aria-hidden, and
tabIndex={-1} so it can never intercept clicks or steal keyboard focus while
invisible. If it is still document.activeElement when it hides (keyboard
activation without focusTarget, or Home pressed while it had focus), it
blurs itself in an effect keyed on visible, so focus never rests on an
aria-hidden node. The forwarded ref is merged with an internal one in a
useCallback so this check has the node.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the surface,
hover:bg-primary/90, shadow-lg, focus-visible:ring-ring +
focus-visible:ring-offset-2 for the focus ring. Circular size-10 button
with a centered lucide ArrowUp icon.
- Visibility transitions via opacity + a small translate-y, motion-reduce:
transition-none so the state still flips instantly with animations off.
- Fixed positioning (bottom-6 plus right-6 or left-6 depending on position);
merge the consumer's className with cn() last, so a consumer can override
"fixed" with e.g. "absolute" to dock the button inside a positioned
container instead of the viewport.
Customization levers
- threshold: lower it for panels that are short but still worth a quick
return-to-top; raise it for long-form pages where the button shouldn't
compete with content near the fold.
- target: point it at a scrollable <div ref> to scope the button to one
panel (chat window, side drawer) instead of the whole page. Listeners bind
to the node present at mount — if you conditionally swap the scroll
container behind the same ref, remount the button with a key.
- focusTarget: point it at the page's <main tabIndex={-1}> (or the panel's
first heading) so keyboard users land at the top of what they scrolled
back to; without it they only lose focus from the hidden button.
- position: swap corners to avoid colliding with another fixed element
(chat launcher, cookie banner) already docked to one side.
- Layout mode: for an always-on-page button, mount it once at the layout
level with the default fixed positioning; for an in-panel button (as in
this demo), override className to "absolute" and give the wrapper
`position: relative` so it docks to the panel instead of the viewport.
- Pair with a progress-meter ring or scroll-progress bar around/above the icon to
show how far there is to scroll back, not just that scrolling is possible.Concepts
- Scroll-threshold reveal — visibility is derived from a single low-frequency boolean (past/under threshold), not from the raw scroll position, so the button never re-renders on every pixel of scroll.
- rAF-throttled scroll listener — the passive scroll handler only ever schedules one
requestAnimationFrameat a time; extra scroll events between ticks are dropped instead of queued. - Target-container-or-window — the same component reads either an element's own
scrollTopor the page'sscrollY, so it works identically pinned to the viewport or scoped to one scrollable panel viatarget. - Claim-to-own scroll, not navigation — the click handler only ever calls
scrollToon an existing scroll position; it never touches routing, so it stays honest about not being a table-of-contents or anchor jump. - Reduced-motion honesty — the scroll itself falls back to an instant jump under
prefers-reduced-motion, and the show/hide transition drops its duration, but the button's function (appearing, scrolling) never turns off. - Always-mounted, never-focusable-hidden — the button stays in the DOM through hide/show for the transition to play, but
pointer-events-none+aria-hidden+tabIndex={-1}keep it fully out of the click and tab order while invisible, and a button that is still focused when it hides blurs itself. - Focus follows the scroll — scrolling back to the top moves the viewport but not the keyboard; handing focus to a
focusTargetat the top (withpreventScroll) makes the next Tab continue from the content the user returned to.