2

I have a piece of code that adds a different css class to elements if scrolled into view from top and bottom.

To achieve this, the code recognises four states:

  1. Scrolled into view from top (adds class "inview-top")
  2. Scrolled into view from bottom (adds class "inview-bottom")
  3. Scrolled out of view at the top (adds class "outview-top")
  4. Scrolled out of view at the bottom (adds class "outview-top")

It also removes any inview classes when adding outview classes and vice versa.

My problem is that it uses the Intersection Observer API to achieve this and it seems to be super unreliable. It works perfectly when the observed elements are only below each other but when they are next to each other in one row, it becomes extremely buggy. Many times it does not fire the callback at all. In my example this means that most DIVs stay invisible even though they should become visible as soon as they're scrolled into view.

That is why I would like to know a reliable method to achieve the desired result. It should perform well no matter how many elements are on a page and no matter where they are placed.

You can try my code on jsFiddle or see it here:

const config = {
  root: null,
  rootMargin: '0px',
  threshold: [0.15, 0.2, 0.25, 0.3]
};

let previousY = 0;
let previousRatio = 0;


let observer = new IntersectionObserver(function(entries) {
  entries.forEach(entry => {
    const currentY = entry.boundingClientRect.y
    const currentRatio = entry.intersectionRatio
    const isIntersecting = entry.isIntersecting
    const element = entry.target;

    element.classList.remove("outview-top", "inview-top", "inview-bottom", "outview-bottom");
    // Scrolling up
    if (currentY < previousY) {
      const className = (currentRatio >= previousRatio) ? "inview-top" : "outview-top";
      element.classList.add(className);

      // Scrolling down
    } else if (currentY > previousY) {
      const className = (currentRatio <= previousRatio) ? "outview-bottom" : "inview-bottom";
      element.classList.add(className);
    }

    previousY = currentY
    previousRatio = currentRatio
  })
}, config);

const viewbox = document.querySelectorAll('.viewme');
viewbox.forEach(image => {
  observer.observe(image);
});
body {
  text-align: center;
}
.hi {
  padding: 40vh 0;
  background: lightblue;
}
.box {
  width: 23%; /* change this to 100% and it works fine */
  height: 40vh;
  margin-bottom: 10px;
  background: blue;
  display: inline-block;
}

.viewme {
  opacity: 0;
  transform: translateY(20px);
  transition: all .3s ease;
}

.inview-top, .inview-bottom {
  opacity: 1;
  transform: translateY(0);
}

.outview-top {
  opacity: 0;
  transform: translateY(-20px);
}
.outview-bottom {
  opacity: 0;
  transform: translateY(20px);
}
<p class="hi">There should always be four blue boxes in one row. Scroll down and back up</p>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
Jascha Goltermann
  • 1,074
  • 2
  • 16
  • 31
  • This is really weird behaviour and should not happen. Can you put your boxes in a row-wrapper and observe single rows? and add your classes/animations depending on the row? – cloned May 20 '20 at 11:51
  • I already thought of that as well but then it would be useless on my website where the same DIV elements are in a single row on desktop but in multiple rows on mobile. – Jascha Goltermann May 20 '20 at 13:01
  • The code above already works perfectly when the DIVs width is set to 100% because then there is always just one DIV per row and the IntersectionObserver doesn't seem to have any problems with that. – Jascha Goltermann May 20 '20 at 13:04

2 Answers2

3

The problem is with previousY and previousRatio variables. They are being changed by every element. So when iteration process current element the variables had already been altered by the previous element.

The way to resolve it is to change both variables to arrays and adding the values of each entry to them. This way each modification is separated from other elements.

I changed class to data attributes. It provides more solid swapping and cleanup.

Using IntersectionObserver:

The way to resolve it is to calculate the div top relevant to root top and the bottom of div relevant to the root bottom. This way you do not need to add global variables or arrays to store previous position or rations.

const config = {
  // Add root here so rootBounds in entry object is not null
  root: document,
  // Margin to when element should take action
  rootMargin: '-50px',
  // Fine tune threshold. The callback will fired 30 times during intersection. You can change it to any number yout want
  threshold: [...Array(30).keys()].map(x => x / 29)
};

let observer = new IntersectionObserver(function(entries, observer) {

  entries.forEach((entry, index) => {
    const element = entry.target;

    // Get root elemenet (document) coords
    const rootTop = entry.rootBounds.top;
    const rootBottom = entry.rootBounds.height;

    // Get div coords
    const topBound = entry.boundingClientRect.top - 50; // -50 to count for the margine in config
    const bottomBound = entry.boundingClientRect.bottom;

    let className;

    // Do calculations to get class names
    if (topBound < rootTop && bottomBound < rootTop) {
      className = "outview-top";
    } else if (topBound > rootBottom) {
      className = "outview-bottom";
    } else if (topBound < rootBottom && bottomBound > rootBottom) {
      className = "inview-bottom";
    } else if (topBound < rootTop && bottomBound > rootTop) {
      className = "inview-top";
    }
    element.setAttribute('data-view', className);

  })
}, config);

const viewbox = document.querySelectorAll('.viewme');
viewbox.forEach(image => {
  observer.observe(image);
});
body {
  text-align: center;
}

.margins {
  position: fixed;
  top: 50px;
  bottom: 50px;
  border-top: 2px dashed;
  border-bottom: 2px dashed;
  z-index: 1;
  left: 0;
  width: 100%;
  pointer-events: none;
}

.hi {
  padding: 40vh 0;
  background: lightgray;
}

.box {
  width: 23%;
  /* change this to 100% and it works fine */
  height: 40vh;
  margin-bottom: 10px;
  background: lightblue;
  display: inline-block;
}

.viewme {
  opacity: 0;
  transform: translateY(20px);
  transition: all .3s ease;
}

.viewme[data-view='inview-top'],
.viewme[data-view='inview-bottom'] {
  opacity: 1;
  transform: translateY(0);
}

.viewme[data-view='outview-top'] {
  opacity: 0;
  transform: translateY(-20px);
}

.viewme[data-view='outview-bottom'] {
  opacity: 0;
  transform: translateY(20px);
}
<p class="hi">There should always be four blue boxes in one row. Scroll down and back up</p>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>

<div class='margins'>

</div>

Alternative solution:

Using scroll event. This approach is more reliable than IntersectionObserver and less complicated. However, it might cause some lag (for very large number of elements).

const viewbox = document.querySelectorAll('.viewme');
const containerHeight = window.innerHeight;

window.addEventListener("scroll", function(e) {
  const direction = (this.oldScroll > this.scrollY) ? "up" : "down";
  this.oldScroll = this.scrollY;

  viewbox.forEach((element, index) => {
    element.viewName = element.viewName || "";
    const rect = element.getBoundingClientRect();
    const top = rect.top + 50;
    const bottom = rect.bottom - 50;

    if (direction == "down") {
      if (top > 0 && top < containerHeight)
        element.viewName = "inview-bottom";
      else if (top < 0 && bottom < 0)
        element.viewName = "outview-top";

    } else {
      if (top > containerHeight)
        element.viewName = "outview-bottom";
      else if (top < 0 && bottom > 0)
        element.viewName = "inview-top";
    }
    element.setAttribute('data-view', element.viewName);
  });
});

// Trigger scroll on initial load
window.dispatchEvent(new Event('scroll'));
body {
  text-align: center;
}

.margins {
  position: fixed;
  top: 50px;
  bottom: 50px;
  border-top: 2px dashed;
  border-bottom: 2px dashed;
  z-index: 1;
  left: 0;
  width: 100%;
  pointer-events: none;
}

.hi {
  padding: 40vh 0;
  background: lightgray;
}

.box {
  width: 23%;
  /* change this to 100% and it works fine */
  height: 40vh;
  margin-bottom: 10px;
  background: lightblue;
  display: inline-block;
}

.viewme {
  opacity: 0;
  transform: translateY(20px);
  transition: all .3s ease;
}

.viewme[data-view='inview-top'],
.viewme[data-view='inview-bottom'] {
  opacity: 1;
  transform: translateY(0);
}

.viewme[data-view='outview-top'] {
  opacity: 0;
  transform: translateY(-20px);
}

.viewme[data-view='outview-bottom'] {
  opacity: 0;
  transform: translateY(20px);
}
<p class="hi">There should always be four blue boxes in one row. Scroll down and back up</p>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>

<div class='margins'>

</div>
Kalimah
  • 11,217
  • 11
  • 43
  • 80
  • Thank you, that seems to work much better! And thanks for the explaination as well. I still have two problems with the "reliability" of the IntersectionObeserver though. 1. When I scroll really fast and suddenly stop scrolling, it often misses a few of the observed elements. This becomes more easy to reproduce when the threshold only includes one value instead of four. 2. When changing browser tabs in chrome, the IntersectionOberserver acts really confusing. Try opening a new tab and coming back to see what I mean. Any ideas? – Jascha Goltermann May 22 '20 at 11:48
  • I guess for this to fix you have to adjust the check inside the observer. There is no case for when the whole element is visible and if there is no change in scroll direction. Maybe with my approach it's easier to handle if you have a `else if (isFullElementVisible(...) )` check at the end (or maybe at first check, since if full element is visible don't do anything) – cloned May 22 '20 at 11:53
  • I don't understand any of that but it sounds like you have an idea how to fix this. Would you be able to try it out with your code? – Jascha Goltermann May 22 '20 at 12:09
  • I updated my answer with comments inside the code because it is hard to explain all the changes in the comments. – Kalimah May 22 '20 at 15:22
  • 1
    Wow, both of your solutions work very well! And you're right, the alternative solution is much more reliable when scrolling fast. I will use that because I know I won't have very many elements on the page anyway. Thanks! – Jascha Goltermann May 22 '20 at 17:33
  • Hi @Kalimah, I just noticed that your solution with IntersectionObserver doesn't work in any browser on my iPhone. I figured out that it is because you set `root: document`. I tried other pieces of code using `root: null` that work fine on mobile. However, we can not set `root: null` in your code because of `rootBounds`. Do you have any idea how to solve this? – Jascha Goltermann May 28 '20 at 07:00
  • @JaschaGoltermann maybe you can wrap your html in a div and set the id of the div as the root element. – Kalimah May 30 '20 at 00:53
  • @Kalimah Thanks for the suggestion. I’ve tried that but couldn’t get it to work. I’ve posted a new question for this issue. Maybe you can help to solve it there? https://stackoverflow.com/questions/62084306/intersectionobserver-not-working-in-safari-or-ios – Jascha Goltermann May 30 '20 at 07:10
  • Look, I have tried to do that here: https://jsfiddle.net/sublines/6sfuj2yo/97/ It doesn't seem to work with rootBounds.. – Jascha Goltermann May 31 '20 at 06:12
  • Unfortunately I don't have an iPhone or iMac to debug your issue. – Kalimah May 31 '20 at 08:10
  • @Kalimah Too bad - I've put another bounty on the question :) – Jascha Goltermann May 31 '20 at 14:38
0

As Kalimah already stated, the problem with your approach lies with how you handle the global variables. If element 1 scrolls into view, you change the variable and all the rest of the elements on this row will not see any direction change and will not animate.

Another approach you can use is something like this, check if the element top/bottom is outside of view. This now only works for scrolling down, but I think you get the idea and can expand it to however you need it.

const config = {
  root: null,
  rootMargin: '0px',
  threshold: [0.15, 0.2, 0.25, 0.3]
};

let observer = new IntersectionObserver(function(entries) {
  entries.forEach(entry => {
    const currentY = entry.boundingClientRect.y;
    const currentRatio = entry.intersectionRatio;
    const isIntersecting = entry.isIntersecting;
    const element = entry.target;
    element.classList.remove("outview-top", "inview-top", "inview-bottom", "outview-bottom");
    if (isTopVisible(element) ){
     element.classList.add('inview-top');
    } else if (isBottomVisible(element) ) {
     element.classList.add('inview-bottom');
    }

  })
}, config);

function isTopVisible(element) {
 const elementTop = element.getBoundingClientRect().top;
 const scrollTop = document.documentElement.scrollTop;
 return ( scrollTop > elementTop);
}

function isBottomVisible(element) {
 const elementBottom = element.getBoundingClientRect().bottom;
 const scrollBottom = document.documentElement.scrollTop + document.documentElement.clientHeight;
 return (scrollBottom > elementBottom);
}

const viewbox = document.querySelectorAll('.viewme');
viewbox.forEach(image => {
  observer.observe(image);
});
.hi {
  padding: 40vh 0;
  background: lightblue;
  text-align:center;
}
.box {
  width: 23%; /* change this to 100% and it works fine */
  height: 40vh;
  margin-bottom: 10px;
  background: blue;
  display: inline-block;
}

.viewme {
  opacity: 0;
  transform: translateY(20px);
  transition: all .3s ease;
}

.inview-top, .inview-bottom {
  opacity: 1;
  transform: translateY(0);
}

.outview-top {
  opacity: 0;
  transform: translateY(-20px);
}
.outview-bottom {
  opacity: 0;
  transform: translateY(20px);
}
<p class="hi">There should always be four blue boxes in one row. Scroll down and back up</p>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
<div class="box viewme"></div>
cloned
  • 6,346
  • 4
  • 26
  • 38
  • Thanks for the suggestion. However, I need to be able to add a different animation depending on whether the observed elements enter or leave the viewport at the top or the bottom. With your code, I can only add one when they enter at the bottom.. – Jascha Goltermann May 22 '20 at 12:08
  • As I wrote in my answer: This is not the finished code, you will have to expand it with different checks (`isBottomVisible` is prepared ) ... you may have to do something like: `isBottomVisibleAndTopNotVisible()` meaning it comes in from the top... This is one general approach, check with `getBoundingClientRect()` and `scrollTop` how much of an element is visible and add/remove classes based on this. – cloned May 22 '20 at 12:14
  • Thank you for trying to help! Unfortunately, I did not write the jQuery I posted and don't really understand it. That's why I put the 100 rep bounty on the question because I am looking for a detailed answer that solves all the concerns. – Jascha Goltermann May 22 '20 at 12:32
  • There is no jQuery involved here at all. Just plain normal JavaScript. – cloned May 22 '20 at 14:16
  • See, I didn't even know that. So would you help me out achieving the desired result? – Jascha Goltermann May 22 '20 at 14:51