Animations with the Intersection Observer API

Any element with class*="animate-" will be set to opacity: 0; by default. Using an Intersection Observer we can then detect whenever this element (entry) appears within the viewport (intersects) as the user scrolls down on the page. Once it has entered the viewport then the observer will append the appropriate class to the element so that its opacity is set to 1, and then stop observing the element. We can also add other CSS properties such as transform: translate(); for other effects such as sliding in to place.

There are also two data attributes that can be set as parameters for each individual animation. This includes data-animation-delay and data-animation-speed. Both accept numeric values which represent the amount of time in milliseconds.

data-animation-delay
How much time (in milliseconds) should elapse before the animation occurs after the element has been observed as intersecting.

data-animation-speed
How much time (in milliseconds) the animation should run for.

You can also use the fade-in-stagger class which append the animate-fade-in class to each respective child element. This creates a “staggering” animation effect which means that each child element animates into place only after the one before it.

As per my last post, this example also includes the HTML CSS for a simple header, navigation menu, masthead photo slideshow section, and main content area.

This example also includes JavaScript for setting the masthead photo slideshow section’s photo to take up the entire available height of the viewport so long as that is <=960px.

CodePen

JavaScript

// Created by Alex Winter
// Last Modified: 2022-07-06




// **************************************************
// Masthead Slideshow Height
// **************************************************
const header = document.querySelector('#site-header');
const masthead = document.querySelector('.masthead-slideshow .glide');

// ternary operator
// if the element is null (undefined and doesnt exist in DOM) then set its offsetHeight value to 0
const headerHeight = header != null ? header.offsetHeight : 0; // get header height

// define viewport height
let viewportHeight = window.innerHeight;

const setMastheadHeight = () => {
    // calculate masthead height
    let mastheadHeight = viewportHeight - headerHeight;

    // apply masthead height value to masthead element
    if (masthead) {
        masthead.style.height = mastheadHeight  + 'px';
    };
};
setMastheadHeight();




// **************************************************
//  Intersection Observer Animations
// **************************************************
const intersectionObserverAnimations = () => {
    // fade-in-stagger
    const fadeInStagger = document.querySelectorAll('.fade-in-stagger')
    fadeInStagger.forEach((item) => {
        // get the speed value
        const speed = item.dataset.animationSpeed

        // get the delay value and convert to integer
        let delay = parseInt(item.dataset.animationDelay)

        // get the delay value again but this will never change and be used to increment the delay value for each child item
        const delayUnmutable = delay

        // get each child element
        const child = item.querySelectorAll(':scope > *')

        child.forEach((childElement) => {
            // add the 'fade-in' class which will be used in the IntersectionObserver later on
            childElement.classList.add('animate-fade-in')

            // set the speed value
            childElement.dataset.animationSpeed=speed

            // set the delay to the current delay value
            childElement.dataset.animationDelay=delay

            // increment the delay value by itself
            // this creates the stagger effect!
            delay = delay + delayUnmutable
        })
    })

    // slide-in
    const slideIn = document.querySelectorAll('.animate-slide-in')
    // determine the direction that the element should come from
    // positive values = right
    // negative values = left
    slideIn.forEach((item) => {
        if (item.dataset.slideInPosition != null) {
            const position = item.dataset.slideInPosition
            item.style.transform = 'translateX(' + position + 'px)'
        }
    })

    // all animations (including fade-in)
    const animation = () => {
        let animationObserver = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                // if the entry is in the viewport, add the '_show' class and then stop observing the element
                const animate = () => {
                    let effect = null;
                    if (entry.target.classList.contains('animate-fade-in')) {
                        effect = 'fade-in'
                    } else if (entry.target.classList.contains('animate-slide-in')) {
                        effect = 'slide-in'
                    }

                    entry.target.classList.toggle(effect + '_show', entry.isIntersecting)
                    if (entry.isIntersecting) animationObserver.unobserve(entry.target)
                }

                // speed
                if (entry.target.dataset.animationSpeed != null) {
                    const speed = entry.target.dataset.animationSpeed
                    entry.target.style.transitionDuration = speed + 'ms'
                }

                // delay
                if (entry.target.dataset.animationDelay != null) {
                    const delay = entry.target.dataset.animationDelay
                    setTimeout(() => {
                        animate()
                    }, delay)
                    return
                }

                // fire the animation
                animate()
            })
        },
        {
            // options
            threshold: 0.5,
            // root: null,
            // rootMargin: '-100px 0',
        }
        )
        // tell the observer to watch each element
        animateElement.forEach(item => {
            animationObserver.observe(item)
        })
    }

    // get every element that should be animated
    const animateElement = document.querySelectorAll('[class*="animate-"]')

    animation()
}
intersectionObserverAnimations()
// Todo:
// Figure out how to have different 'options' (threshold, root, etc) for each individual element

HTML

<header id="site-header">
    <div class="header-container">
        <div class="header-logo animate-fade-in" data-animation-delay="125" data-animation-speed="1500">
            <a href="/">
              <svg viewBox="0 0 138 26" fill="none" stroke="orange" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" title="CodePen"><path d="M15 8a7 7 0 100 10m7-8.7L33 2l11 7.3v7.4L33 24l-11-7.3zm0 0l11 7.4 11-7.4m0 7.4L33 9.3l-11 7.4M33 2v7.3m0 7.4V24M52 6h5a7 7 0 010 14h-5zm28 0h-9v14h9m-9-7h6m11 1h6a4 4 0 000-8h-6v14m26-14h-9v14h9m-9-7h6m11 7V6l11 14V6"></path></svg>
            </a>
        </div>

        <div class="header-nav">
            <nav class="nav-traditional" aria-label="Navigation Menu">
                <ul class="fade-in-stagger" data-animation-delay="250" data-animation-speed="1500">
                    <li class="nav-item standard"><a href="/" class="nav-link">About</a></li>
                    <li class="nav-item standard"><a href="/" class="nav-link">Services</a></li>
                    <li class="nav-item standard"><a href="/" class="nav-link">Portfolio</a></li>
                    <li class="nav-item standard"><a href="/" class="nav-link">Blog</a></li>
                    <li class="nav-item standard"><a href="/" class="nav-link">Contact</a></li>
                </ul>
            </nav>
        </div>
    </div>
</header>

<section id="site-masthead" role="complementary" aria-label="Photo Slideshow">
    <div class="masthead-slideshow">
        <div class="masthead-content animate-fade-in" data-animation-delay="1500" data-animation-speed="1500">
            <div class="masthead-content-container">
                <h1>Building Cool Things Since 1988</h1>
            </div>
        </div>

        <div class="glide">
            <img src="https://images.unsplash.com/photo-1541457523724-95f54f7740cc" alt="">
        </div>
    </div>
</section>

<main>
    <section class="content-split">
        <div class="content-split-item content-split-text animate-slide-in" data-slide-in-position="-100" data-slide-in-delay="0" data-slide-in-speed="1000">
            <h2>Site Content</h2>
            <p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Distinctio mollitia nostrum, praesentium provident officia odit possimus commodi blanditiis fugiat animi recusandae, necessitatibus assumenda cumque vel, molestias quo.</p>
        </div>

        <div class="content-split-item content-split-image animate-slide-in" data-slide-in-position="100" data-slide-in-delay="0" data-slide-in-speed="1000">
            <img src="https://images.unsplash.com/photo-1542324253097-09d465f007bc" alt="">
        </div>
    </section>
</main>

CSS

body {
  margin: 0;
  background-color: #000;
  color: #fff;
  font-family: 'Monterrat', sans-serif;
}

h1,
h2 {
  margin: 0 0 0.5em 0;
  line-height: 1;
}

h1 {
  font-size: 3rem;
  
  @media (max-width: 1300px) {
    font-size: 2rem;
  }
}

h2 {
  font-size: 2.5rem;
}

p {
  margin: 0;
  font-size: 1.5rem;
  line-height: 1.75;
}

.text-center {
  text-align: center;
}




// **************************************************
//  Header
// **************************************************
header#site-header {
  position: sticky;
  top: 0;
  width: 100%;
  z-index: 10000;
  background-color: #323233;
}




// **************************************************
//  Header Container
// **************************************************
.header-container {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  gap: 2em;
  max-width: 1920px;
  margin-inline: auto;
  padding-block: 2.25em;
  padding-inline: 1em;

  @media (max-width: 1360px) {
    flex-direction: column;
    gap: 0;
    padding-block: 1em;
  }
}




// **************************************************
//  Header Logo
// **************************************************
.header-logo {
  flex-grow: 0;
  flex-shrink: 1;
  flex-basis: auto;
  display: flex;
  justify-content: center;
  flex-direction: column;
  align-items: center;

  a {
    width: 420px;
    
    @media (max-width: 1360px) {
      width: 210px;
    }
  }

  img {
    display: block;
  }
}




// **************************************************
//  Header Nav
// **************************************************
.header-nav {
  display: flex;
  flex-direction: row;
  justify-content: center;
  align-items: center;
}

.nav-traditional {
  margin: 0;
  padding: 0;
  opacity: 1;
  display: block;

  >ul {
    display: flex;
    gap: 0.25em;
    margin: 0;
    padding: 0;
    list-style: none;
    
    @media(max-width: 540px) {
      gap: 0;
    }

    >li {
      position: relative;
      opacity: 0;

      >a {
        display: block;
        padding-block: 0.6em;
        padding-inline: 1.2em;
        color: #fff;
        font-size: 1.4rem;
        font-family: 'Montserrat', sans-serif;
        line-height: 1;
        font-weight: 400;
        text-decoration: none;
        
        @media (max-width: 1360px) {
          font-size: 1rem;
        }
        
        @media(max-width: 540px) {
          padding-inline: 0.75em
        }
      }

      &.standard {
        >a {
          transition: all 0.5s;

          &:hover,
          &:focus{
            background-color: #5A5F73;
            color: #fff;
          }
        }
      }
    }
  }
}




// **************************************************
//  Masthead Slideshow
// **************************************************
#site-masthead {
  position: relative;
  background-color: #000;
}

.masthead-slideshow {
  .glide {
    max-height: 960px;
  }

  img {
    display: block;
    object-fit: cover;
    width: 100%;
    height: 100%;
    max-width: none;
  }
}

.masthead-content {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  padding-inline: 1em;
  z-index: 101;
  color: #fff;
  
  h1 {
    margin: 0 0 0.25em 0;
    text-align: center;
  }
} 




// **************************************************
//  Main
// **************************************************
main {
  padding-block: 5em;
  padding-inline: 1em;
}




// **************************************************
//  Content
// **************************************************
.content-split {
  display: flex;
  overflow: hidden;
}

.content-split-item {
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 50%;
}

.content-split-text {
  
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.content-split-image {
  min-width: 0;

  img {
    display: block;
    width: 100%;
    height: auto;
    max-height: 600px;
    object-fit: cover;
  }
}




// **************************************************
//  Intersection Observer Animations
// **************************************************
[class*="animate-"] {
  opacity: 0;
  transition: 1500ms;

  &.fade-in_show {
    opacity: 1;
  }

  &.slide-in_show {
    transform: translateX(0) !important;
    opacity: 1;
  }
}

CodePen