Add different classes to elements if scrolled into view from top and bottom

Viewed 1942

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>

2 Answers

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>

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>

Related