Go Back

Detecting When Scrolling Stops in JavaScript (and React)

Posted: 
Last modified: 

Have you ever needed to know when a user stops scrolling? It sounds simple, but as I discovered, it’s trickier than it looks. Between new browser events like scrollend, programmatic scrolls with window.scrollTo, and edge cases across devices, it’s easy to get stuck in loops or fire too many callbacks.

In this post I’ll walk through some of the problems I hit, and the different approaches you can take to reliably detect “scroll end.”


The Problem: Knowing When Scrolling Ends

The scroll event is easy enough — it fires constantly while the user scrolls. But what I wanted was a debounced “scroll has finished” event that I could use in a React app. Think of things like:

  • Hiding a floating toolbar until the user stops moving.
  • Triggering animations after scrolling completes.
  • Tracking engagement when a reader settles on a section.

First Attempt: Framer Motion useScroll

Since I was already using Framer Motion, my first try was to hook into its useScroll. It provides reactive values like scrollY, and I wrapped it with a little timeout logic:

const { scrollY } = useScroll();
useEffect(() => {
const unsubscribe = scrollY.on("change", () => {
clearTimeout(timeout);
timeout = setTimeout(() => onScrollEnd(), delay);
});
return unsubscribe;
}, [scrollY]);

This worked well enough, but it’s essentially just debouncing the raw scroll events.


Enter the scrollend Event

More recently, browsers have been shipping a native scrollend event. Perfect! In theory I could just do:

window.addEventListener("scrollend", () => {
console.log("User stopped scrolling");
});

But two problems cropped up:

  • Support isn’t universal — Safari, for example, doesn’t fire it yet.
  • Programmatic scrolls also trigger scrollend — so calling window.scrollTo ended up firing my callback and creating infinite loops.

Distinguishing User vs. Programmatic Scrolls

To solve the loop issue, I explored a few approaches:

  • Flagging programmatic scrolls
    Wrap scrollTo calls in a helper that sets a boolean, and ignore scrollend while it’s true.
  • Comparing scroll positions
    Before calling scrollTo, check if window.scrollY is already within a pixel or two of the target. If so, skip it — no need to trigger an event. function safeScrollTo(targetY: number) { if (Math.abs(window.scrollY - targetY) < 1) return; window.scrollTo({ top: targetY, behavior: "smooth" }); }
  • Looking at user input
    Track recent wheel, touchmove, or arrow key events. If scrollend fires without a recent user action, assume it was programmatic.

Polyfilling scrollend

Since support is patchy, I also considered simulating the event myself. That way, consumers of my code could always just listen to scrollend — whether real or polyfilled.

function setupScrollEndPolyfill(delay = 150) {
let timeout: ReturnType<typeof setTimeout> | null = null;

window.addEventListener("scroll", () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
window.dispatchEvent(new Event("scrollend"));
}, delay);
});
}

// Only install polyfill if native isn't supported
if (!("onscrollend" in window)) {
setupScrollEndPolyfill();
}

Now my app code doesn’t need to know the difference.


Wrapping It Up in React

For React apps, it’s convenient to encapsulate this into a hook:

import { useEffect } from "react";

export function useScrollEnd(callback: () => void, delay = 150) {
useEffect(() => {
const handler = () => callback();
window.addEventListener("scrollend", handler);

if (!("onscrollend" in window)) {
let timeout: ReturnType<typeof setTimeout> | null = null;
const scrollHandler = () => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => window.dispatchEvent(new Event("scrollend")), delay);
};
window.addEventListener("scroll", scrollHandler);
return () => {
window.removeEventListener("scroll", scrollHandler);
window.removeEventListener("scrollend", handler);
};
}

return () => window.removeEventListener("scrollend", handler);
}, [callback, delay]);
}


Takeaways

  • Native scrollend is coming, but you can’t rely on it everywhere yet.
  • Programmatic scrolling complicates things — add guards so you don’t fire false “user” events.
  • A polyfill approach lets you treat everything consistently and just listen for one event.
  • In React, wrapping the whole thing into a custom hook keeps your components clean.

👉 If you’re dealing with scroll-based interactions today, I’d recommend starting with a polyfill + hook. As browser support improves, you can let the native event take over automatically.