React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

scrollinfinite-scrollsticky-headerscroll-positionreact-19typescript

Scroll Events

Respond to scroll position changes for infinite scroll, sticky headers, scroll-to-top buttons, and progress indicators.

Event Reference

Event / MethodFires WhenEvent Object / Notes
onScrollThe element's scroll position changesReact.UIEvent
onScrollCaptureSame as onScroll but during the capture phaseReact.UIEvent
window.addEventListener("scroll", ...)The page-level scroll position changesNative Event (use in useEffect)
IntersectionObserverAn element enters or exits the viewportNot an event -- API-based (use in useEffect)

Key properties on a scrollable element:

PropertyTypeDescription
scrollTopnumberPixels scrolled from the top
scrollLeftnumberPixels scrolled from the left
scrollHeightnumberTotal scrollable height including overflow
clientHeightnumberVisible height of the element
scrollWidthnumberTotal scrollable width including overflow
clientWidthnumberVisible 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", ...) in useEffect for 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 onScroll event fires on any element with overflow: auto or overflow: scroll when its scroll position changes.
  • For page-level scrolling, React's onScroll on the root element is unreliable -- use window.addEventListener("scroll", ...) inside useEffect instead.
  • 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 IntersectionObserver API is a performant alternative for detecting when elements enter the viewport, since it runs off the main thread.
  • scrollHeight - clientHeight gives the maximum scrollTop value 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

  • onScroll on the body/document does not work in React -- React's onScroll only fires on elements with their own scrollbar, not the page itself. Fix: Use window.addEventListener("scroll", handler) inside useEffect for page-level scroll.

  • Scroll handlers cause jank when doing expensive work -- onScroll fires on every frame during active scrolling. Setting state on every event causes re-renders that block the main thread. Fix: Debounce the handler, use requestAnimationFrame throttling, or switch to IntersectionObserver for visibility checks.

  • Missing { passive: true } on window scroll listeners -- Without this hint, the browser cannot optimize scrolling because it waits to see if preventDefault() is called. Fix: Always pass { passive: true } when adding scroll listeners that do not call preventDefault().

  • scrollHeight is 0 on initial render -- If you read scrollHeight during the first render or in a useEffect before content is painted, the value may be wrong. Fix: Wait until after layout with useLayoutEffect or 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 scrollTop before the update and restore it after using useLayoutEffect, or use the flushSync utility 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 check entry.isIntersecting before triggering your load-more logic.

  • scroll-behavior: smooth in CSS conflicts with programmatic scrollTo -- If CSS applies smooth scrolling globally, your scrollTo({ behavior: "instant" }) calls may still animate. Fix: Use behavior: "instant" explicitly or remove the CSS rule and apply smooth scrolling only via JS.

Alternatives

AlternativeUse WhenDon't Use When
IntersectionObserverYou need to detect element visibility (lazy load, infinite scroll)You need the exact scroll position value
CSS position: stickyYou want a sticky header or sidebarYou need JS logic based on sticky state
CSS scroll-snapYou want snap-to-item scrollingYou need programmatic control over snap behavior
react-virtuoso / @tanstack/virtualYou have thousands of items in a scrollable listThe list is short (under 100 items)
requestAnimationFrame throttleYou need smooth scroll-linked animationsA 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 onScroll only works on elements that have their own scrollbar (with overflow: auto or overflow: scroll)
  • Page-level scrolling happens on document or window, not on your component
  • Use window.addEventListener("scroll", handler) inside useEffect for 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?
  • IntersectionObserver is 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
  • onScroll requires 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" });
}