Skip to content

How to detect when scrolling ends

Daisho Komiyama edited this page Jan 20, 2024 · 10 revisions

The Element interface's scrollIntoView() method scrolls the element's ancestor containers such that the element on which scrollIntoView() is called is visible to the user. https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView

One day, I had to know when the scrolling ends. I have implemented navigation with a nice cursor (like a hockey puck) that animates, I mean, it moves from left to right to indicate where the user is. Imagine a site with a vertically long single page. The cursor's positionX changes depending on the scrollY of the page.

This worked perfectly when I manually scrolled. For example, I manually scroll down to the contact section of the page. The cursor swiftly runs through each menu item and then stops at the center of "Contact" menu. Nice.

But things got rough when I triggered the scrolling by clicking. The page starts scrolling to get where I specified, but the cursor shows a jitter; it tries to stop momentarily when the scrollY corresponds to each menu item, but the scrolling is ongoing; the cursor keeps moving too, then it encounters another stop. It slows down near the menu item, but since the scrolling is still ongoing, the cursor cancels the stop and moves again toward the right. This happens within less than a second, but it is evident that there's something wrong.

So the solution is to cancel the instruction. I have to do it only when the scrolling takes place by clicking. I can give the position for the cursor where it stops because I click the menu item. I can get offsetLeft value of the menu item, so I simply pass it to the cursor so it knows the X coordinate of the destination. I want it to ignore all other menu items when I give it a location like this. But once it arrives, I want it immediately to start listening to the scrollY position to react page's scrollY value.

To do that, I need to know when the scrolling ends. I can set timeout (setTimout) with whatever number roughly works. But I will never know how much time it actually needs. 1 sec, or 2 sec? Even 200 milliseconds? On Firefox, the scrolling is guaranteed to end in 500ms, but Chrome doesn't seem to care. I have no idea how it is on Safari.

I don't cross my fingers. I make sure.

The API - scrollend event

There's API for this type of detection, but at this point of writing (Mar 2023), it is not fully or even partially supported. (supported only by Chrome Canary) https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event

So this is a polyfill/workaround for now. Promise-based scrollIntoView.

const scrollIntoViewPromise = (elem: HTMLElement) => {
  return new Promise<void>(resolve => {
    const tolerance = 2 // how many frames to wait before determining scroll-end
    let frames = 0
    let lastPositionY: number | null

    elem.scrollIntoView({ behavior: 'smooth' })
    requestAnimationFrame(checkPosition)

    function checkPosition() {
      const newPositionY = elem.getBoundingClientRect().top
      if (newPositionY === lastPositionY) {
        if (frames++ > margin) {
          return resolve()
        }
      } else {
        frames = 0
        lastPositionY = newPositionY
      }
      requestAnimationFrame(checkPosition)
    }
  })
}

N.B., I know there's a CSS property scroll-behavior. It works fine, just like scrollIntoView. We probably won't need JS to do this anymore, but it doesn't solve said problem. We still have no idea when the scrolling ends. We need JS to know it.

html {
  scroll-behavior: smooth;
}
Clone this wiki locally