React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

scrollnavigationbuttonuicustom-hook

useScrollToTop — Scroll the window to the top and show a "back to top" button

Recipe

import { useState, useEffect, useCallback } from "react";
 
interface UseScrollToTopOptions {
  /** Scroll distance (px) before the button appears. Default: 300 */
  threshold?: number;
  /** Use smooth scrolling. Default: true */
  smooth?: boolean;
}
 
interface UseScrollToTopReturn {
  /** Whether the page has scrolled past the threshold */
  isVisible: boolean;
  /** Call this to scroll to the top */
  scrollToTop: () => void;
  /** Current scroll position */
  scrollY: number;
}
 
function useScrollToTop(
  options: UseScrollToTopOptions = {}
): UseScrollToTopReturn {
  const { threshold = 300, smooth = true } = options;
  const [isVisible, setIsVisible] = useState(false);
  const [scrollY, setScrollY] = useState(0);
 
  useEffect(() => {
    const handleScroll = () => {
      const y = window.scrollY;
      setScrollY(y);
      setIsVisible(y > threshold);
    };
 
    // Check initial position
    handleScroll();
 
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => window.removeEventListener("scroll", handleScroll);
  }, [threshold]);
 
  const scrollToTop = useCallback(() => {
    window.scrollTo({
      top: 0,
      behavior: smooth ? "smooth" : "instant",
    });
  }, [smooth]);
 
  return { isVisible, scrollToTop, scrollY };
}

When to reach for this: You have a long page and want to give users a quick way to return to the top, with the button only appearing after they have scrolled down a meaningful distance.

Working Example

"use client";
 
function BackToTopButton() {
  const { isVisible, scrollToTop } = useScrollToTop({
    threshold: 400,
    smooth: true,
  });
 
  return (
    <button
      onClick={scrollToTop}
      aria-label="Scroll to top"
      style={{
        position: "fixed",
        bottom: 24,
        right: 24,
        width: 48,
        height: 48,
        borderRadius: "50%",
        border: "none",
        background: "#111",
        color: "#fff",
        fontSize: 20,
        cursor: "pointer",
        opacity: isVisible ? 1 : 0,
        transform: isVisible ? "translateY(0)" : "translateY(16px)",
        transition: "opacity 0.3s, transform 0.3s",
        pointerEvents: isVisible ? "auto" : "none",
      }}
    >

    </button>
  );
}
 
function LongPage() {
  return (
    <div>
      <h1>Article Title</h1>
      {Array.from({ length: 50 }, (_, i) => (
        <p key={i}>Paragraph {i + 1} of content...</p>
      ))}
      <BackToTopButton />
    </div>
  );
}

What this demonstrates:

  • The button fades in after scrolling past 400 px
  • Clicking the button smoothly scrolls to the top of the page
  • CSS transitions handle the show/hide animation
  • pointerEvents: "none" prevents the invisible button from blocking clicks
  • aria-label ensures screen reader accessibility

Deep Dive

How It Works

  • A single scroll event listener (with { passive: true } for performance) tracks window.scrollY.
  • isVisible flips to true when scrollY exceeds the threshold, giving the consuming component a reactive boolean for rendering.
  • scrollToTop calls window.scrollTo with behavior: "smooth" for a native smooth scroll animation.
  • The passive: true option tells the browser the handler will not call preventDefault, allowing scroll performance optimizations.
  • Initial position is checked immediately in the effect so the button state is correct on mount (e.g., if the user refreshes mid-page).

Parameters & Return Values

ParameterTypeDefaultDescription
options.thresholdnumber300Pixels scrolled before isVisible is true
options.smoothbooleantrueWhether to use smooth scroll behavior
ReturnTypeDescription
isVisiblebooleanWhether scroll position exceeds threshold
scrollToTop() => voidFunction to scroll to top
scrollYnumberCurrent scroll Y position

Variations

Throttled scroll handler: For pages with heavy rendering, wrap the scroll handler with useThrottledCallback to reduce state updates:

const handleScroll = useThrottledCallback(() => {
  setScrollY(window.scrollY);
  setIsVisible(window.scrollY > threshold);
}, 100);

Scroll to element: Extend to scroll to any element ref instead of the top:

const scrollToElement = useCallback((ref: React.RefObject<HTMLElement>) => {
  ref.current?.scrollIntoView({ behavior: smooth ? "smooth" : "instant" });
}, [smooth]);

TypeScript Notes

  • The options object uses an interface with optional properties, giving IDE autocompletion.
  • Return type is a named interface for easy reuse and documentation.
  • No generics needed since all types are concrete.

Gotchas

  • Performance on heavy pages — Updating state on every scroll event can cause jank on complex pages. Fix: Throttle the handler or use requestAnimationFrame.
  • SSR crashwindow is not available during server-side rendering. Fix: The useEffect only runs on the client, so the hook is SSR-safe as written. Default state (isVisible: false) is correct for SSR.
  • Smooth scroll not supported — Very old browsers ignore behavior: "smooth". Fix: The page still scrolls instantly, which is an acceptable fallback.
  • Fixed position conflicts — A fixed button can overlap mobile navigation bars. Fix: Adjust bottom value or add z-index to layer correctly.

Alternatives

PackageHook/ComponentNotes
react-scrollanimateScroll.scrollToTop()Full scroll library with link components
usehooks-tsuseScrollPositionTracks position but no scroll-to function
ahooksuseScrollReturns full scroll state for any element
framer-motionuseScrollAnimation-focused scroll tracking
Native CSSscroll-behavior: smoothCSS-only, no button logic

FAQs

What does the isVisible boolean represent and when does it flip?
  • isVisible is true when window.scrollY exceeds the threshold value.
  • It flips to false when the user scrolls back above the threshold.
  • It is checked on every scroll event and also on mount.
Why is the scroll listener registered with { passive: true }?

The passive flag tells the browser the handler will never call preventDefault(). This allows the browser to optimize scroll performance by not waiting for the handler to finish before scrolling.

How does smooth scrolling work and what happens in browsers that do not support it?
  • window.scrollTo({ behavior: "smooth" }) triggers a native smooth scroll animation.
  • Unsupported browsers ignore the behavior property and scroll instantly.
  • The instant scroll is an acceptable fallback since the user still reaches the top.
Why does the effect call handleScroll() immediately after registering the listener?

This handles the case where the user refreshes the page while already scrolled down. Without the initial call, isVisible would remain false until the next scroll event.

How can I throttle the scroll handler to reduce state updates on heavy pages?

Wrap the handler with useThrottledCallback:

const handleScroll = useThrottledCallback(() => {
  setScrollY(window.scrollY);
  setIsVisible(window.scrollY > threshold);
}, 100);
Gotcha: My fixed "back to top" button overlaps the mobile navigation bar. How do I fix it?
  • Increase the bottom CSS value to push the button above the nav bar.
  • Add a z-index to ensure proper layering.
  • Test on both iOS and Android since their nav bar heights differ.
Gotcha: The button blocks clicks on content behind it even when invisible. Why?

If opacity: 0 is set without pointerEvents: "none", the button still receives click events. The working example sets pointerEvents: isVisible ? "auto" : "none" to prevent this.

Is this hook SSR-safe?

Yes. The useEffect only runs on the client, and the default state values (isVisible: false, scrollY: 0) are correct for server-rendered output. No typeof window guard is needed outside the effect.

How would I type the options and return value in TypeScript if I wanted to pass them as props?

Use the named interfaces directly:

interface Props {
  scrollOptions: UseScrollToTopOptions;
}
 
function MyComponent({ scrollOptions }: Props) {
  const result: UseScrollToTopReturn = useScrollToTop(scrollOptions);
}
How could I extend this hook to scroll to any element instead of the top?

Add a scrollToElement function using scrollIntoView:

const scrollToElement = useCallback(
  (ref: React.RefObject<HTMLElement>) => {
    ref.current?.scrollIntoView({
      behavior: smooth ? "smooth" : "instant",
    });
  },
  [smooth]
);