Search code examples
javascripthtmlcsscss-animationsonscroll

How to get exact scroll position when animating elements onScroll


I am trying to animate elements while scrolling based on the elements progress through the scene. I am running into the issue of the browser returning intermittent scroll positions making it difficult to be exact when starting and stopping element animation.

Minimal example:

Fiddle Fiddle Demo

const tweens = document.querySelectorAll('.tween');
const trigger = document.querySelector('.start');
const triggerRect = trigger.getBoundingClientRect();

let yScroll = window.scrollY;
let yCurrent = yScroll;
let AF = null;

const start = triggerRect.top - yScroll - 200;
const end = start + 400;

const updateScroll = () => {
	yScroll = window.scrollY;
  startAnimation();
}

const startAnimation = () => {
  if(!AF) AF = requestAnimationFrame(animate)
}

const cancelAnimation = () => {
  yCurrent = yScroll;
  cancelAnimationFrame(AF);
  AF = null;
}

const animate = () => {
  if(yCurrent === yScroll) cancelAnimation();
  else {
  	updateElements();
    yCurrent = yScroll;
    AF = requestAnimationFrame(animate);
  }
}

const updateElements = () => {
  const pos = yCurrent - start;
  if(inScene()){
    tweens[0].style.transform = `translateX(${pos}px)`;
    tweens[1].style.transform = `translateY(${pos}px)`;
  }
}

const inScene = () => {
  return start <= yCurrent && end >= yCurrent ? true : false;
};


window.addEventListener('scroll', updateScroll);
.wrapper{
  position:relative;
  margin: 100vh 20px;
  background: rgb(240,240,240);
  height: 900px;
  border: 1px solid red;
}

.line{
  position:absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background: red;
}

.start{
  top: 200px;
}

.end{
  bottom: 200px;
}

.tween{
  position:absolute;
  top:200px;
  width: 100px;
  height: 100px;
  background: blue;
}

.left{
  left: 0;
}

.right{
  right: 0;
}
<h1>Scroll Down</h1>
<div class="wrapper">
  <div class="line start trigger"></div>
  <div class="line end"></div>
  <div class="tween left"></div>
  <div class="tween right"></div>
</div>

As you can see the elements are supposed to stop on the lines but when you scroll they never really do. And the faster you scroll the worse it becomes.

So is there a technique to either return the correct scroll position or somehow debounce the elements so then can reach the full range when scrolling instead of constantly coming up short of their intended position?

I know this is possible because scrolling libraries like ScrollMagic do a pretty good job of handling this but I don't really want to deconstruct then entire ScrollMagic framework to find out how they achieve it. I would use the ScrollMagic framework itself but I am trying to have a momentum style scrolling container wrapping the page and translating it on scroll along with elements inside this container and using ScrollMagic it is pretty buggy. So I figured I would post the question here and see if anyone had any experience or insight on the situation.

Any guidance would be appreciated as I have been mulling over this for a while.


Solution

  • Let's assume that browser is what it is and not all pixels travelled by will result in an event. You end up with elements away from the end because you check for the scroll position if it's "in scene". When it's 5x before - it's fine. Next thing it is 5px after - you ignore it. What you can do is to animate objects to their final positions whenever you scroll outside the scene instead of ignoring it.

    So just to start it, I would change this:

    const updateElements = () => {
      let pos = yCurrent - start;
      pos = Math.min(400, Math.max(0, pos));
      tweens[0].style.transform = `translateX(${pos}px)`;
      tweens[1].style.transform = `translateY(${pos}px)`;
    }
    

    It can be optimised more so it will start animation only once when it's outside the "scene". Let me know if you have problem with that as well.

    UPDATE:

    Here's the version that animates only if needed:

    const updateElements = () => {
      let pos = yCurrent - start;
      pos = Math.min(400, Math.max(0, pos));
      const styles = [...tweens].map(tween => window.getComputedStyle(tween, null));
      const transforms = styles.map(style => new WebKitCSSMatrix(style.getPropertyValue('transform')));
      if (transforms[0].m41 !== pos) {
        tweens[0].style.transform = `translateX(${pos}px)`;
      }
    
      if (transforms[1].m42 !== pos) {
        tweens[1].style.transform = `translateY(${pos}px)`;
      }
    }
    

    Reading translate is hell, so if we care only about Webkit browsers we can use WebKitCSSMatrix, but Firefox doesn't have anything like that so you can use something external

    And I agree with artanik it does work better without requestAnimationFrame