CSS3-Animate elements if visible in viewport (Page Scroll)

Still Javascript, but with this version you don't need to listen on scroll events. Speed and performace is much better than check each time if an object is in the viewport.

Check this: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

With Intersection Observer, you can define a callback when an element is visible.

Options:
root: null << Set to null if you want it inside your viewport (visible area)
threshold: 0.3 << means 30% visibility. If you set 0.3, the callback is called once when the visibility reach at least 30% and once it is visible for less than 30%.

function callbackFunc(entries, observer)
{
  entries.forEach(entry => {
    var txt = entry.target.id + " visibility: " + entry.isIntersecting;
    
    document.getElementById('log').appendChild(document.createTextNode(txt));
    document.getElementById('log').appendChild(document.createElement("br"));
  });
}

let options = {
    root: null,
    rootMargin: '0px',
    threshold: 0.3
  };

let observer = new IntersectionObserver(callbackFunc, options);

observer.observe(document.getElementById('firstBlock'));
observer.observe(document.getElementById('secondBlock'));
#firstBlock {
  width: 50vw;
  height: 80vh;
  background: red;
}

#secondBlock {
  width: 50vw;
  height: 80vh;
  background: blue;
}

#log {
  width: 200px;
  height: 80vh;
  position: fixed;
  right: 0px;
  top: 10px;
  overflow: auto;
}
First Block:
<div id='firstBlock'> </div>
<br><br><br>
Second Block:
<div id='secondBlock'> </div>
<div id='log'>Log: </div>

Another approach is using a scroll event listener

document.addEventListener("DOMContentLoaded", function(event) {
    document.addEventListener("scroll", function(event) {
        const animatedBoxes = document.getElementsByClassName("animated-box");
        const windowOffsetTop = window.innerHeight + window.scrollY;

        Array.prototype.forEach.call(animatedBoxes, (animatedBox) => {
            const animatedBoxOffsetTop = animatedBox.offsetTop;

            if (windowOffsetTop >= animatedBoxOffsetTop) {
                addClass(animatedBox, "fade-in");
            }
        });
    });
});

function addClass(element, className) {
    const arrayClasses = element.className.split(" ");
    if (arrayClasses.indexOf(className) === -1) {
        element.className += " " + className;
    }
}
.animated-box {
  width: 150px;
  height: 150px;
  margin-top: 100vh;
  background: blue;
}

.fade-in {
    -webkit-animation: fade-in 1.2s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
            animation: fade-in 1.2s cubic-bezier(0.390, 0.575, 0.565, 1.000) both;
}

 @-webkit-keyframes fade-in {
  0% {
    -webkit-transform: translateY(50px);
            transform: translateY(50px);
    opacity: 0;
  }
  100% {
    -webkit-transform: translateY(0);
            transform: translateY(0);
    opacity: 1;
  }
}
@keyframes fade-in {
  0% {
    -webkit-transform: translateY(50px);
            transform: translateY(50px);
    opacity: 0;
  }
  100% {
    -webkit-transform: translateY(0);
            transform: translateY(0);
    opacity: 1;
  }
}
<div>
  Start scrolling down...
  
  <div class="animated-box">

  </div>
        
  <div class="animated-box">
        
  </div>

  <div class="animated-box">
  
  </div>
  
  <div class="animated-box">
  
  </div>
  
  <div class="animated-box">
  
  </div>
</div>

Using IntersectionObserver API

The IntersectionObserver API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

Here's an example that triggers a classList toggle when an Element is in viewport:

const inViewport = (entries, observer) => {
  entries.forEach(entry => {
    entry.target.classList.toggle("is-inViewport", entry.isIntersecting);
  });
};

const Obs = new IntersectionObserver(inViewport);
const obsOptions = {}; //See: https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Intersection_observer_options

// Attach observer to every [data-inviewport] element:
const ELs_inViewport = document.querySelectorAll('[data-inviewport]');
ELs_inViewport.forEach(EL => {
  Obs.observe(EL, obsOptions);
});
[data-inviewport] { /* THIS DEMO ONLY */
  width:100px; height:100px; background:#0bf; margin: 150vh 0; 
}

/* inViewport */

[data-inviewport="scale-in"] { 
  transition: 2s;
  transform: scale(0.1);
}
[data-inviewport="scale-in"].is-inViewport { 
  transform: scale(1);
}

[data-inviewport="fade-rotate"] { 
  transition: 2s;
  opacity: 0;
}
[data-inviewport="fade-rotate"].is-inViewport { 
  transform: rotate(180deg);
  opacity: 1;
}
Scroll down...
<div data-inviewport="scale-in"></div>
<div data-inviewport="fade-rotate"></div>

Observer Options

To define another parent reference element use the root option inside the Observable options Object. At your disposition there's also rootMargin and the super useful threshold option

const obsOptions = {
  // Default is null (Browser viewport). Set a specific parent element:
  root: document.querySelector('#someSpecificParent'),
  // add 40px inner "margin" area at which the observer starts to calculate:
  rootMargin: '40px', 
  // Default is 0.0 meaning the callback is called as soon 1 pixel is inside the viewport.  
  // Set to 1.0 to trigger a callback when 100% of the target element is inside the viewport,   
  // or i.e: 0.5 when half of the target element is visible:
  threshold: 0.5, 
};

See another interesting use case that uses the IntersectionObserver API's threshold option.

Additional read:

  • w3.org
  • developer.mozilla.org
  • caniuse.com IE browser

Using the native IntersectionObserver API is the most performant way to tackle this problem.
If you want an idea on how we tackled similar needs in the past see this answer with a small custom plugin as an example.