How to know scroll to element is done in Javascript?
Asked Answered
G

11

86

I am using Javascript method Element.scrollIntoView()
https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView

Is there any way I can get to know when the scroll is over. Say there was an animation, or I have set {behavior: smooth}.

I am assuming scrolling is async and want to know if there is any callback like mechanism to it.

Gateshead answered 17/10, 2017 at 17:34 Comment(1)
A working solution in VanillaJS, without setTimeout https://mcmap.net/q/243748/-is-there-a-callback-for-window-scrolltoZoba
D
54

There is no scrollEnd event, but you can listen for the scroll event and check if it is still scrolling the window:

var scrollTimeout;
addEventListener('scroll', function(e) {
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(function() {
        console.log('Scroll ended');
    }, 100);
});
Denouement answered 2/7, 2018 at 19:20 Comment(5)
This worked perfectly for me. My scrollIntoView() was triggering an unrelated scroll function, so I removed the listener before the scrollIntoView, but I needed a way to know when the scroll is finished to re-add the listener. And the good thing is that adding the event listener here with a named function prevents it from being repeatedly added.Fussy
Took me a few moments to grok what is actually going on here. For anybody else puzzling over it, the solution here assumes that scroll events fire more frequently than every 100ms while scrolling is taking place, so if we have a window of 100ms where no further scroll events trigger, that must be the end of the scrolling action. Seems to work well, and is compliant with the MDN advice to not perform any computationally complex actions within a scroll event.Leathery
It was failing for me on mobile for some scrolls, so I just set it on 200. Seems working well now, since here are no better solutions anyway.Tavish
Thanks! Perhaps I would add a removeEventListener on scroll endDavidson
There is now a scrollend event.March
M
40

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>
March answered 10/9, 2019 at 9:0 Comment(6)
Nice approach. I'm wondering though, why waiting 2 frames and not more (or less)? Feels like a magic number.Acro
@Sagivb.g IIRC I added this fool check because I experienced some browsers were waiting a full frame to start scrolling, and calling rAF from a non animated document actually doesn't wait the next frame. So it happened that the script was seeing the animation as over even before it actually did start. But yes, that's a magic number.March
caniuse/scrollend_event currently at 2.3%.Osy
This does not work when the animation is short, because it can be slow and the values can be the same between 2 requestAnimationFrame calls :/Pantomime
@Allen use scrollendMarch
Browser support for scrollend has jumped to 71% as of 2024-01-22.Osy
T
28

You can use IntersectionObserver, check if element .isIntersecting at IntersectionObserver callback function

const element = document.getElementById("box");

const intersectionObserver = new IntersectionObserver((entries) => {
  let [entry] = entries;
  if (entry.isIntersecting) {
    setTimeout(() => alert(`${entry.target.id} is visible`), 100)
  }
});
// start observing
intersectionObserver.observe(element);

element.scrollIntoView({behavior: "smooth"});
body {
  height: calc(100vh * 2);
}

#box {
  position: relative;
  top:500px;
}
<div id="box">
box
</div>
Terrorist answered 17/10, 2017 at 17:54 Comment(8)
IntersectionObserver is not supported in Safari :(Gateshead
@SushantGupta "IntersectionObserver is not supported in Safari" .scrollIntoView() is not supported at Safari either according to link to MDN at Question.Terrorist
scrollIntoView is working for me in Safari. Just that {behavior: "smooth"} is not supported. caniuse.com/#search=scrollintoviewGateshead
Also, in the documentation scrollIntoViewOptions is not supported in Safari but scrollIntoView is supported.Gateshead
Cool feauture. But in your code using box and element variables both. It something stunes..Repeat
I guess this would work only if the 'box' element wasn't visible before scrolling. But it might be visible the whole time, and the IntersectionObserver won't be able to detect it.Arin
smooth behavior is supported in Safari with css scroll-behavior: smoothErode
entry.isIntersecting - how to check if its entry.isIntersected ?Predate
M
16

I stumbled across this question as I wanted to focus a particular input after the scrolling is done (so that I keep the smooth scrolling).

If you have the same usecase as me, you don't actually need to wait for the scroll to be finished to focus your input, you can simply disable the scrolling of focus.

Here is how it's done:

window.scrollTo({ top: 0, behavior: "smooth" });
myInput.focus({ preventScroll: true });

cf: https://github.com/w3c/csswg-drafts/issues/3744#issuecomment-685683932

Btw this particular issue (of waiting for scroll to finish before executing an action) is discussed in CSSWG GitHub here: https://github.com/w3c/csswg-drafts/issues/3744

Masoretic answered 12/10, 2021 at 12:11 Comment(2)
clean and best solution.Mortenson
Awesome! Exactly my use case. Extra kudos for the references :)Marileemarilin
O
6

Solution that work for me with rxjs

lang: Typescript

scrollToElementRef(
    element: HTMLElement,
    options?: ScrollIntoViewOptions,
    emitFinish = false,
  ): void | Promise<boolean> {
    element.scrollIntoView(options);
    if (emitFinish) {
      return fromEvent(window, 'scroll')
        .pipe(debounceTime(100), first(), mapTo(true)).toPromise();
    }
  }

Usage:

const element = document.getElementById('ELEM_ID');
scrollToElementRef(elment, {behavior: 'smooth'}, true).then(() => {
  // scroll finished do something
})
Overijssel answered 18/4, 2020 at 23:54 Comment(0)
E
3

The accepted answer is great but I nearly didn't use it because of it's verbosity. Here's a simpler vanillajs version that should speak for itself:

    scrollTarget.scrollIntoView({
        behavior: "smooth",
        block: "center",
    });
    let lastPos = null;
    requestAnimationFrame(checkPos);
    function checkPos() {
        const newPos = scrollTarget.getBoundingClientRect().top;
        if (newPos === lastPos) {
            console.log('scroll finished on', scrollTarget);
        } else {
            lastPos = newPos;
            requestAnimationFrame(checkPos);
        }
    }

I've omitted the check where OP was worried that the raf would fire twice in quick succession without the scroll changing; maybe that's a valid fear but I've not come across that problem.

Ethiopia answered 15/5, 2023 at 16:42 Comment(0)
A
2

These answers above leave the event handler in place even after the scrolling is done (so that if the user scrolls, their method keeps getting called). They also don't notify you if there's no scrolling required. Here's a slightly better answer:

$("#mybtn").click(function() {
    $('html, body').animate({
        scrollTop: $("div").offset().top
    }, 2000);

    $("div").html("Scrolling...");

    callWhenScrollCompleted(() => {
        $("div").html("Scrolling is completed!");
    });
});

// Wait for scrolling to stop.
function callWhenScrollCompleted(callback, checkTimeout = 200, parentElement = $(window)) {
  const scrollTimeoutFunction = () => {
    // Scrolling is complete
    parentElement.off("scroll");
    callback();
  };
  let scrollTimeout = setTimeout(scrollTimeoutFunction, checkTimeout);

  parentElement.on("scroll", () => {
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(scrollTimeoutFunction, checkTimeout);
  });
}
body { height: 2000px; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<button id="mybtn">Scroll to Text</button>
<br><br><br><br><br><br><br><br>
<div>example text</div>
Alchemy answered 26/8, 2019 at 18:17 Comment(0)
T
1

i'm not an expert in javascript but i made this with jQuery. i hope it helps

$("#mybtn").click(function() {
    $('html, body').animate({
        scrollTop: $("div").offset().top
    }, 2000);
});

$( window ).scroll(function() {
  $("div").html("scrolling");
  if($(window).scrollTop() == $("div").offset().top) {
    $("div").html("Ended");
  }
})
body { height: 2000px; }
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

<button id="mybtn">Scroll to Text</button>
<br><br><br><br><br><br><br><br>
<div>example text</div>
Tortious answered 2/7, 2018 at 20:2 Comment(0)
K
0

I recently needed callback method of element.scrollIntoView(). So tried to use the Krzysztof Podlaski's answer. But I could not use it as is. I modified a little.

import { fromEvent, lastValueFrom } from 'rxjs';
import { debounceTime, first, mapTo } from 'rxjs/operators';

/**
 * This function allows to get a callback for the scrolling end
 */
const scrollToElementRef = (parentEle, childEle, options) => {
  // If parentEle.scrollTop is 0, the parentEle element does not emit 'scroll' event. So below is needed.
  if (parentEle.scrollTop === 0) return Promise.resolve(1);
  childEle.scrollIntoView(options);
  return lastValueFrom(
    fromEvent(parentEle, 'scroll').pipe(
      debounceTime(100),
      first(),
      mapTo(true)
    )
  );
};

How to use

scrollToElementRef(
  scrollableContainerEle, 
  childrenEle, 
  {
    behavior: 'smooth',
    block: 'end',
    inline: 'nearest',
  }
).then(() => {
  // Do whatever you want ;)
});
Kiefer answered 5/1, 2022 at 16:16 Comment(0)
S
0

In case someone is looking for a way to recognize a scrollEnd event in Angular by means of a directive:

/**
 * As soon as the current scroll animation ends
 * (triggered by scrollElementIntoView({behavior: 'smooth'})),
 * this method resolves the returned Promise.
 */
@Directive({
    selector : '[scrollEndRecognizer]'
})
export class ScrollEndDirective {

    @Output() scrollEnd: EventEmitter<void> = new EventEmitter();

    private scrollTimeoutId: number;

    //*****************************************************************************
    //  Events
    //****************************************************************************/
    @HostListener('scroll', [])
    public emitScrollEndEvent() {
        // On each new scroll event, clear the timeout.
        window.clearTimeout(this.scrollTimeoutId);

        // Only after scrolling has ended, the timeout executes and emits an event.
        this.scrollTimeoutId = window.setTimeout(() => {
            this.scrollEnd.emit();
            this.scrollTimeoutId = null;
        }, 100);
    }

    /////////////////////////////////////////////////////////////////////////////*/
    //  END Events
    /////////////////////////////////////////////////////////////////////////////*/
}
Sacks answered 20/7, 2023 at 14:31 Comment(0)
H
0

We can make use of Promises to achieve this through the scrollend event

const betterScrollIntoView = function(el, options = {}) {
    return new Promise(function(resolve, reject) {
        window.addEventListener("scrollend", (e) => {
            resolve();
        }, { once: true });
    
        el.scrollIntoView(options);
    });
}

then you can use it like this:

betterScrollIntoView(document.body, {
    behavior: 'smooth'
}).then(function() {
    console.log("Scrolling finished")
});

Do note though that at the present, the scrollend event has limited compatibility (mainly still unsupported by the Apple Safari). See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollend_event#browser_compatibility

Hal answered 19/5 at 11:10 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.