Scroll Events
Respond to scroll position changes for infinite scroll, sticky headers, scroll-to-top buttons, and progress indicators.
Event Reference
| Event / Method | Fires When | Event Object / Notes |
|---|---|---|
onScroll | The element's scroll position changes | React.UIEvent |
onScrollCapture | Same as onScroll but during the capture phase | React.UIEvent |
window.addEventListener("scroll", ...) | The page-level scroll position changes | Native Event (use in useEffect) |
IntersectionObserver | An element enters or exits the viewport | Not an event -- API-based (use in useEffect) |
Key properties on a scrollable element:
| Property | Type | Description |
|---|---|---|
scrollTop | number | Pixels scrolled from the top |
scrollLeft | number | Pixels scrolled from the left |
scrollHeight | number | Total scrollable height including overflow |
clientHeight | number | Visible height of the element |
scrollWidth | number | Total scrollable width including overflow |
clientWidth | number | Visible width of the element |
Recipe
Quick-reference recipe card -- copy-paste ready.
// Container scroll event
"use client";
import { useCallback } from "react";
export function ScrollableList() {
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
const scrollPercentage = scrollTop / (scrollHeight - clientHeight);
if (scrollPercentage > 0.9) {
// Near bottom -- load more
}
}, []);
return (
<div className="h-96 overflow-y-auto" onScroll={handleScroll}>
{/* content */}
</div>
);
}// Window scroll listener via useEffect
"use client";
import { useState, useEffect } from "react";
export function useWindowScroll() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return scrollY;
}When to reach for this: You need to respond to how far a user has scrolled -- progress bars, lazy loading, sticky elements, or back-to-top buttons.
Working Example
// ScrollProgressPage.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
export default function ScrollProgressPage() {
const [progress, setProgress] = useState(0);
const [showBackToTop, setShowBackToTop] = useState(false);
useEffect(() => {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } =
document.documentElement;
const totalScrollable = scrollHeight - clientHeight;
const currentProgress =
totalScrollable > 0 ? (scrollTop / totalScrollable) * 100 : 0;
setProgress(currentProgress);
setShowBackToTop(scrollTop > 400);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
return (
<>
{/* Progress bar fixed at top */}
<div className="fixed top-0 left-0 w-full h-1 bg-gray-200 z-50">
<div
className="h-full bg-blue-600 transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
</div>
{/* Page content */}
<main className="max-w-2xl mx-auto p-6 pt-8">
<h1 className="text-3xl font-bold mb-6">Long Article</h1>
{Array.from({ length: 20 }, (_, i) => (
<section key={i} className="mb-8">
<h2 className="text-xl font-semibold mb-2">Section {i + 1}</h2>
<p className="text-gray-700 leading-relaxed">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris.
</p>
</section>
))}
</main>
{/* Back to top button */}
{showBackToTop && (
<button
onClick={scrollToTop}
className="fixed bottom-6 right-6 w-12 h-12 bg-blue-600 text-white
rounded-full shadow-lg flex items-center justify-center
hover:bg-blue-700 transition-colors"
aria-label="Back to top"
>
↑
</button>
)}
</>
);
}What this demonstrates:
- Using
window.addEventListener("scroll", ...)inuseEffectfor page-level scroll tracking - Computing scroll progress as a percentage of total scrollable height
- Conditionally showing a back-to-top button based on scroll position
- Using
{ passive: true }for scroll performance optimization - Cleaning up the event listener on unmount
Deep Dive
How It Works
- The
onScrollevent fires on any element withoverflow: autooroverflow: scrollwhen its scroll position changes. - For page-level scrolling, React's
onScrollon the root element is unreliable -- usewindow.addEventListener("scroll", ...)insideuseEffectinstead. - Scroll events fire at a very high rate (every frame during active scrolling). Heavy handlers cause jank because they run on the main thread.
- The
IntersectionObserverAPI is a performant alternative for detecting when elements enter the viewport, since it runs off the main thread. scrollHeight - clientHeightgives the maximumscrollTopvalue for any scrollable container.
Variations
Infinite scroll with IntersectionObserver:
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
type Item = { id: number; title: string };
export function InfiniteList() {
const [items, setItems] = useState<Item[]>(() =>
Array.from({ length: 20 }, (_, i) => ({ id: i, title: `Item ${i}` }))
);
const [loading, setLoading] = useState(false);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
setLoading(true);
// Simulate API call
await new Promise((r) => setTimeout(r, 500));
setItems((prev) => {
const start = prev.length;
const next = Array.from({ length: 20 }, (_, i) => ({
id: start + i,
title: `Item ${start + i}`,
}));
return [...prev, ...next];
});
setLoading(false);
}, []);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !loading) {
loadMore();
}
},
{ rootMargin: "200px" }
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [loading, loadMore]);
return (
<div className="max-w-md mx-auto">
{items.map((item) => (
<div key={item.id} className="p-4 border-b">
{item.title}
</div>
))}
<div ref={sentinelRef} className="h-4" />
{loading && <p className="text-center p-4">Loading...</p>}
</div>
);
}Sticky header on scroll:
"use client";
import { useState, useEffect } from "react";
export function StickyHeader() {
const [isSticky, setIsSticky] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsSticky(window.scrollY > 80);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<header
className={`w-full transition-all duration-200 ${
isSticky
? "fixed top-0 bg-white/95 backdrop-blur shadow-sm z-40"
: "relative bg-transparent"
}`}
>
<nav className="max-w-6xl mx-auto px-6 py-4">
<h1 className="text-lg font-bold">My Site</h1>
</nav>
</header>
);
}Scroll position restoration:
"use client";
import { useEffect, useRef } from "react";
export function useScrollRestoration(key: string) {
const restored = useRef(false);
useEffect(() => {
// Restore position
if (!restored.current) {
const saved = sessionStorage.getItem(`scroll-${key}`);
if (saved) {
window.scrollTo(0, Number(saved));
}
restored.current = true;
}
// Save position on scroll
const handleScroll = () => {
sessionStorage.setItem(`scroll-${key}`, String(window.scrollY));
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, [key]);
}Horizontal scroll container:
"use client";
import { useRef, useCallback } from "react";
export function HorizontalScroll({ children }: { children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const scroll = useCallback((direction: "left" | "right") => {
containerRef.current?.scrollBy({
left: direction === "right" ? 300 : -300,
behavior: "smooth",
});
}, []);
return (
<div className="relative">
<button
onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white/80 p-2 rounded-full shadow"
aria-label="Scroll left"
>
←
</button>
<div
ref={containerRef}
className="flex gap-4 overflow-x-auto snap-x snap-mandatory scrollbar-hide px-12"
>
{children}
</div>
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white/80 p-2 rounded-full shadow"
aria-label="Scroll right"
>
→
</button>
</div>
);
}Debounced scroll handler:
"use client";
import { useEffect, useRef, useState } from "react";
export function useDebouncedScroll(delay = 100) {
const [scrollY, setScrollY] = useState(0);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const handleScroll = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
setScrollY(window.scrollY);
}, delay);
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, [delay]);
return scrollY;
}TypeScript Notes
// onScroll handler typed to a specific element
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const target = e.currentTarget; // HTMLDivElement
const top: number = target.scrollTop;
const height: number = target.scrollHeight;
const visible: number = target.clientHeight;
}
// Window scroll in useEffect -- uses native Event, not React.UIEvent
useEffect(() => {
const handler = (e: Event) => {
// window.scrollY is the standard way to read page scroll
console.log(window.scrollY);
};
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// IntersectionObserver typing
const observer = new IntersectionObserver(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry: IntersectionObserverEntry) => {
const ratio: number = entry.intersectionRatio;
const isVisible: boolean = entry.isIntersecting;
const target: Element = entry.target;
});
},
{
root: null, // viewport
rootMargin: "0px",
threshold: [0, 0.25, 0.5, 0.75, 1.0],
} satisfies IntersectionObserverInit
);
// Ref typing for scroll containers
const scrollRef = useRef<HTMLDivElement>(null);Gotchas
-
onScrollon the body/document does not work in React -- React'sonScrollonly fires on elements with their own scrollbar, not the page itself. Fix: Usewindow.addEventListener("scroll", handler)insideuseEffectfor page-level scroll. -
Scroll handlers cause jank when doing expensive work --
onScrollfires on every frame during active scrolling. Setting state on every event causes re-renders that block the main thread. Fix: Debounce the handler, userequestAnimationFramethrottling, or switch toIntersectionObserverfor visibility checks. -
Missing
{ passive: true }on window scroll listeners -- Without this hint, the browser cannot optimize scrolling because it waits to see ifpreventDefault()is called. Fix: Always pass{ passive: true }when adding scroll listeners that do not callpreventDefault(). -
scrollHeightis 0 on initial render -- If you readscrollHeightduring the first render or in auseEffectbefore content is painted, the value may be wrong. Fix: Wait until after layout withuseLayoutEffector measure inside the scroll handler itself. -
Scroll position lost on re-render -- When items are added to the top of a list (like a chat), the scroll position jumps. Fix: Save
scrollTopbefore the update and restore it after usinguseLayoutEffect, or use theflushSyncutility for synchronous DOM updates. -
IntersectionObserver triggers on mount -- The callback fires immediately when
observe()is called if the element is already in the viewport. Fix: Guard with a flag or checkentry.isIntersectingbefore triggering your load-more logic. -
scroll-behavior: smoothin CSS conflicts with programmatic scrollTo -- If CSS applies smooth scrolling globally, yourscrollTo({ behavior: "instant" })calls may still animate. Fix: Usebehavior: "instant"explicitly or remove the CSS rule and apply smooth scrolling only via JS.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
IntersectionObserver | You need to detect element visibility (lazy load, infinite scroll) | You need the exact scroll position value |
CSS position: sticky | You want a sticky header or sidebar | You need JS logic based on sticky state |
CSS scroll-snap | You want snap-to-item scrolling | You need programmatic control over snap behavior |
react-virtuoso / @tanstack/virtual | You have thousands of items in a scrollable list | The list is short (under 100 items) |
requestAnimationFrame throttle | You need smooth scroll-linked animations | A debounced value is sufficient |
FAQs
How do I detect when a user reaches the bottom of a scrollable container?
function handleScroll(e: React.UIEvent<HTMLDivElement>) {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 10) {
// At or near the bottom
}
}Why does onScroll not fire on my page component?
- React's
onScrollonly works on elements that have their own scrollbar (withoverflow: autooroverflow: scroll) - Page-level scrolling happens on
documentorwindow, not on your component - Use
window.addEventListener("scroll", handler)insideuseEffectfor page scroll
How do I throttle a scroll handler without a library?
const ticking = useRef(false);
useEffect(() => {
const handleScroll = () => {
if (!ticking.current) {
requestAnimationFrame(() => {
// Your scroll logic here
ticking.current = false;
});
ticking.current = true;
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);Should I use IntersectionObserver or onScroll for infinite scrolling?
IntersectionObserveris strongly preferred -- it runs off the main thread and is more performant- Place a sentinel
<div>at the bottom of your list and observe it onScrollrequires manual math and fires on every scroll frame, which is wasteful
How do I scroll to a specific element programmatically?
const targetRef = useRef<HTMLDivElement>(null);
function scrollToTarget() {
targetRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}Related
- Touch Events -- Handling touch interactions for swipe and gesture
- Pointer Events -- Unified input across mouse, touch, and pen