How to detect when scrolling ends
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.
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;
}