Buttons

Scroll to Top

A floating button that fades in past a scroll threshold and smoothly scrolls its container back to the top.

Preview in your theme

Loading preview…

"use client"

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

export interface ScrollToTopProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Scroll distance (px) past which the button becomes visible. */
  threshold?: number
  /** Scrollable container to observe; defaults to the window. */
  target?: React.RefObject<HTMLElement | null>
  /** Fixed corner the button docks to. */
  position?: "bottom-right" | "bottom-left"
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-to-top.json

Prompt

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 requestAnimationFrame at 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 scrollTop or the page's scrollY, so it works identically pinned to the viewport or scoped to one scrollable panel via target.
  • Claim-to-own scroll, not navigation — the click handler only ever calls scrollTo on 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.

On This Page