2022 Update:
The CSS specs recently included the overscroll and scrollend proposal, this proposal adds a few CSS overscroll attributes, and more importantly to us, a scrollend
event.
Browsers are still working on implementing it. (It's already available in Chromium under the Web Platforms Experiments flag.)
We can feature-detect it by simply looking for
if (window.onscrollend !== undefined) {
// we have a scrollend event
}
if (window.onscrollend !== undefined) {
document.querySelector("button").onclick = (evt) => {
addEventListener(
"scrollend",
(evt) => console.log("done scrolling"),
{ once: true }
);
document.querySelector(".target").scrollIntoView({
behavior: "smooth",
block: "center"
});
};
}
else {
console.warn("Your browser doesn't support the scrollend event");
}
.obstacle {
background: repeating-linear-gradient(black, black 20px, white 20px, white 40px);
height: 800vh;
}
<button>focus target</button>
<div class=obstacle></div>
<div class=target>Scrolled to me</div>
While waiting for implementations everywhere, the remaining of this answer is still useful if you want to build a polyfill:
For this "smooth" behavior, all the specs say[said] is
When a user agent is to perform a smooth scroll of a scrolling box box to position, it must update the scroll position of box in a user-agent-defined fashion over a user-agent-defined amount of time.
(emphasis mine)
So not only is there no single event that will fire once it's completed, but we can't even assume any stabilized behavior between different browsers.
And indeed, current Firefox and Chrome already differ in their behavior:
- Firefox seems to have a fixed duration set, and whatever the distance to scroll is, it will do it in this fixed duration ( ~500ms )
- Chrome on the other hand will use a speed, that is, the duration of the operation will vary based on the distance to scroll, with an hard-limit of 3s.
So this already disqualifies all the timeout based solutions for this problem.
Now, one of the answers here has proposed to use an IntersectionObserver, which is not a too bad solution, but which is not too portable, and doesn't take the inline
and block
options into account.
So the best might actually be to check regularly if we did stop scrolling. To do this in a non-invasive way, we can start a requestAnimationFrame
powered loop, so that our checks are performed only once per frame.
Here one such implementation, which will return a Promise that will get resolved once the scroll operation has finished.
Note: This code misses a way to check if the operation succeeded, since if an other scroll operation happens on the page, all current ones are cancelled, but I'll leave this as an exercise for the reader.
const buttons = [ ...document.querySelectorAll( 'button' ) ];
document.addEventListener( 'click', ({ target }) => {
// handle delegated event
target = target.closest('button');
if( !target ) { return; }
// find where to go next
const next_index = (buttons.indexOf(target) + 1) % buttons.length;
const next_btn = buttons[next_index];
const block_type = target.dataset.block;
// make it red
document.body.classList.add( 'scrolling' );
smoothScroll( next_btn, { block: block_type })
.then( () => {
// remove the red
document.body.classList.remove( 'scrolling' );
} )
});
/*
*
* Promised based scrollIntoView( { behavior: 'smooth' } )
* @param { Element } elem
** ::An Element on which we'll call scrollIntoView
* @param { object } [options]
** ::An optional scrollIntoViewOptions dictionary
* @return { Promise } (void)
** ::Resolves when the scrolling ends
*
*/
function smoothScroll( elem, options ) {
return new Promise( (resolve) => {
if( !( elem instanceof Element ) ) {
throw new TypeError( 'Argument 1 must be an Element' );
}
let same = 0; // a counter
let lastPos = null; // last known Y position
// pass the user defined options along with our default
const scrollOptions = Object.assign( { behavior: 'smooth' }, options );
// let's begin
elem.scrollIntoView( 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 = elem.getBoundingClientRect().top;
if( newPos === lastPos ) { // same as previous
if(same ++ > 2) { // if it's more than two frames
/* @todo: verify it succeeded
* if(isAtCorrectPosition(elem, options) {
* resolve();
* } else {
* reject();
* }
* return;
*/
return resolve(); // we've come to an halt
}
}
else {
same = 0; // reset our counter
lastPos = newPos; // remember our current position
}
// check again next painting frame
requestAnimationFrame(check);
}
});
}
p {
height: 400vh;
width: 5px;
background: repeat 0 0 / 5px 10px
linear-gradient(to bottom, black 50%, white 50%);
}
body.scrolling {
background: red;
}
<button data-block="center">scroll to next button <code>block:center</code></button>
<p></p>
<button data-block="start">scroll to next button <code>block:start</code></button>
<p></p>
<button data-block="nearest">scroll to next button <code>block:nearest</code></button>
<p></p>
<button>scroll to top</button>