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 clicksaria-labelensures screen reader accessibility
Deep Dive
How It Works
- A single
scrollevent listener (with{ passive: true }for performance) trackswindow.scrollY. isVisibleflips totruewhenscrollYexceeds the threshold, giving the consuming component a reactive boolean for rendering.scrollToTopcallswindow.scrollTowithbehavior: "smooth"for a native smooth scroll animation.- The
passive: trueoption tells the browser the handler will not callpreventDefault, 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
| Parameter | Type | Default | Description |
|---|---|---|---|
options.threshold | number | 300 | Pixels scrolled before isVisible is true |
options.smooth | boolean | true | Whether to use smooth scroll behavior |
| Return | Type | Description |
|---|---|---|
isVisible | boolean | Whether scroll position exceeds threshold |
scrollToTop | () => void | Function to scroll to top |
scrollY | number | Current 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 crash —
windowis not available during server-side rendering. Fix: TheuseEffectonly 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
bottomvalue or addz-indexto layer correctly.
Alternatives
| Package | Hook/Component | Notes |
|---|---|---|
react-scroll | animateScroll.scrollToTop() | Full scroll library with link components |
usehooks-ts | useScrollPosition | Tracks position but no scroll-to function |
ahooks | useScroll | Returns full scroll state for any element |
framer-motion | useScroll | Animation-focused scroll tracking |
| Native CSS | scroll-behavior: smooth | CSS-only, no button logic |
FAQs
What does the isVisible boolean represent and when does it flip?
isVisibleistruewhenwindow.scrollYexceeds thethresholdvalue.- It flips to
falsewhen 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
behaviorproperty 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
bottomCSS value to push the button above the nav bar. - Add a
z-indexto 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]
);Related
- useThrottle — throttle the scroll handler
- useEventListener — declarative event binding
- useIntersectionObserver — alternative scroll-based triggers