2023 Update:
There is now a scrollend
event you can listen to. This will fire when the scroll ends, be it from user scroll or from programmatic smooth scroll.
The browser support isn't great yet, but it should be available everywhere soon enough.
if (!("onscrollend" in window)) {
console.warn("Your browser doesn't support the onscrollend event");
}
const trigger = document.getElementById( 'trigger' );
const scroll_forcer = document.getElementById( 'scroll_forcer' );
let scrolling = false; // a simple flag letting us know if we're already scrolling
trigger.onclick = (evt) => startScroll();
function startScroll() {
setTimeout(()=> {
scroll_forcer.classList.add( "scrolling" )
document.scrollingElement.scrollTo( { top: 1000, behavior: "smooth" } );
document.addEventListener( "scrollend", (evt) => {
scroll_forcer.classList.remove( "scrolling" );
}, { once: true });
}, 10);
};
#scroll_forcer {
height: 5000vh;
background-image: linear-gradient(to bottom, red, green);
background-size: 100% 100px;
}
#scroll_forcer.scrolling {
filter: grayscale(70%);
}
<button id="trigger">click to scroll</button>
<div id="scroll_forcer">
</div>
So for the time being, I may still need the previous answer:
There is natively no event to tell when a smooth-scroll ends.
There is also no standard behavior for how this smooth-scroll should occur, no defined duration, no defined timing function (Chrome uses an ease-in-out function while Firefox uses a linear one), and added to that, a smooth-scroll can be canceled in the middle by an other call to the scroll algorithm, or even have no effect at all...
So this detection is not that easy.
The best way I found for now is to start a requestAnimationFrame powered loop which will check every painting frame (right after the scroll operations), if our target is at the same position. As soon as it has been at the same position for more than two frames, we assume the scrolling ended. That's when we can check if it succeeded, simply by checking if we are at the expected position:
const trigger = document.getElementById( 'trigger' );
const scroll_forcer = document.getElementById( 'scroll_forcer' );
let scrolling = false; // a simple flag letting us know if we're already scrolling
trigger.onclick = (evt) => startScroll();
function startScroll() {
setTimeout(()=> {
scroll_forcer.classList.add( "scrolling" )
smoothScrollTo( { top: 1000 } )
.catch( (err) => {
/*
here you can handle when the smooth-scroll
gets disabled by an other scrolling
*/
console.error( 'failed to scroll to target' );
} )
// all done, lower the flag
.then( () => scroll_forcer.classList.remove( "scrolling" ) );
}, 10);
};
/*
*
* Promised based window.scrollTo( { behavior: 'smooth' } )
* @param { Element } elem
** ::An Element on which we'll call scrollIntoView
* @param { object } [options]
** ::An optional scrollToOptions dictionary
* @return { Promise } (void)
** ::Resolves when the scrolling ends
*
*/
function smoothScrollTo( options ) {
return new Promise( (resolve, reject) => {
const elem = document.scrollingElement;
let same = 0; // a counter
// last known scroll positions
let lastPos_top = elem.scrollTop;
let lastPos_left = elem.scrollLeft;
// pass the user defined options along with our default
const scrollOptions = Object.assign( {
behavior: 'smooth',
top: lastPos_top,
left: lastPos_left
}, options );
// expected final position
const maxScroll_top = elem.scrollHeight - elem.clientHeight;
const maxScroll_left = elem.scrollWidth - elem.clientWidth;
const targetPos_top = Math.max( 0, Math.min( maxScroll_top, scrollOptions.top ) );
const targetPos_left = Math.max( 0, Math.min( maxScroll_left, scrollOptions.left ) );
// let's begin
window.scrollTo( scrollOptions );
requestAnimationFrame( check );
// this function will be called every painting frame
// for the duration of the smooth scroll operation
function check() {
// check our current position
const newPos_top = elem.scrollTop;
const newPos_left = elem.scrollLeft;
// we add a 1px margin to be safe
// (can happen with floating values + when reaching one end)
const at_destination = Math.abs( newPos_top - targetPos_top) <= 1 &&
Math.abs( newPos_left - targetPos_left ) <= 1;
// same as previous
if( newPos_top === lastPos_top &&
newPos_left === lastPos_left ) {
if( same ++ > 2 ) { // if it's more than two frames
if( at_destination ) {
return resolve();
}
return reject();
}
}
else {
same = 0; // reset our counter
// remember our current position
lastPos_top = newPos_top;
lastPos_left = newPos_left;
}
// check again next painting frame
requestAnimationFrame( check );
}
});
}
#scroll_forcer {
height: 5000vh;
background-image: linear-gradient(to bottom, red, green);
background-size: 100% 100px;
}
#scroll_forcer.scrolling {
filter: grayscale(70%);
}
.as-console-wrapper {
max-height: calc( 50vh - 30px ) !important;
}
<button id="trigger">click to scroll</button>
<div id="scroll_forcer">
</div>