Scroll to Top
A floating button that fades in past a scroll threshold and smoothly scrolls its container back to the top.
Loading preview…
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
- 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").
- 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".
- 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.
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.
- 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.