DEV Community

Chris Gustin
Chris Gustin

Posted on • Originally published at Medium on

Animate on scroll with the Intersection Observer API


Photo by Pankaj Patel on Unsplash

If you’re building websites these days, it’s a good possibility that your clients will expect animations as part of the deliverable. In the past few years, elements that animate as the user scrolls down the page have gone from “cutting edge” to “status quo.” As such, there are lots of great libraries and plugins for achieving this effect, but it’s also fairly simple to roll your own as it turns out.

Why build your own if there are pre-built solutions available? In many cases, using a library can add a significant amount of extra code and overhead to your project. You may only need a few animated effects, but an animation library will likely load a whole stylesheet full of animation options that will never be used. Likewise, the JavaScript file may include more code than is needed for your situation, as it will be trying to anticipate all the needs a user might have. So while rolling your own is not necessary, it can help reduce code bloat, makes troubleshooting faster, and can help you deliver exactly what a customer or client might be looking for.

Some (subjective) notes about animation

While it might be tempting to throw every animation you can at a page, and go for big dramatic movements, I find it’s often better to go for a few subtle animations that work well together and add interest to the page without going over the top. In my personal opinion, gently fading in elements, or sliding them left/right/up/down a few pixels into their final position (or combining a fade with a slide) generally looks and feels better than elements that wildly spin or bounce onto the page.

Likewise, when choosing how long an element should animate, there’s a sweet spot around 250ms. You can go longer or shorter depending on the style and effect you’re after, but keep in mind that if an animation is too long, the user will start to lose interest, and if it’s too short, it may feel rushed or hurried. You can of course play around to see what you like best, but I’ve usually found the best feel working within these parameters.

With that said, let’s build out our plugin.

The HTML

Let’s start with the HTML and add some elements to our page.

<div class="animate fade-in">I'm going to fade in</div>
<div class="spacer"></div>
<div class="animate fade-in-up">I'm going to fade in and up</div>
<div class="spacer"></div>
<div class="animate fade-in-left">I'm going to fade in and left</div>
<div class="spacer"></div>
<div class="animate fade-in-right">I'm going to fade in and right</div>
Enter fullscreen mode Exit fullscreen mode

(The .spacer elements are included purely to spread out the animated elements in this demo so we can see them animate on scroll and are not required for the functionality.)

For the animated elements, I’ve given each element a class .animate so we can target all elements we want to animate with one JavaScript selector. I’ve also given each element a class that specifies how it will animate, which we’ll define in our CSS file. This way, the code to trigger the animation will live in the JavaScript file and the actual animation logic is handled through the CSS and HTML, making it easy to animate elements or add new animations by simply adding two class names.

The CSS

The .animate class is strictly a JavaScript selector and gets no CSS styling, so we simply define our CSS animations:

.fade-in {
  animation: fade-in .25s ease;
  animation-play-state: paused;
}

.fade-in-up {
  animation: fade-in-up .25s ease;
  animation-play-state: paused;
}

.fade-in-left {
  animation: fade-in-left .25s ease;
  animation-play-state: paused;
}

.fade-in-right {
  animation: fade-in-right .25s ease;
  animation-play-state: paused;
}

.animated {
  animation-play-state: running;
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes fade-in-up {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes fade-in-left {
  from {
    opacity: 0;
    transform: translateX(-4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes fade-in-right {
  from {
    opacity: 0;
    transform: translateX(4px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* For the demo only, not required for functionality */
.spacer {
  height: 100vh;
}
Enter fullscreen mode Exit fullscreen mode

Each class uses the animation keyword to define the name of the animation, the duration for each animation, and the timing function to use (there are lots but I tend to stick with ease as my go-to).

Underneath the classes, I’ve used the @keyframes keyword to define each animation. If you’re not familiar with these, the syntax may look a little weird, but we’re just defining the start position of the animation with the from keyword, and the end position with the to keyword.

Finally, because we only want the animations to play when the elements are visible, I’ve added animation-play-state: paused to each animation class. We’ll use JavaScript to add the .animated class when an element is in view which will switch animation-play-state to running and start our animation.

The JavaScript

While figuring out if an element was in view used to require quite a bit of math and performance draining scroll listeners, the Intersection Observer API makes the task quite a bit simpler and much more performance friendly. To set it up, we simply create a new instance of Intersection Observer like this:

const observer = new IntersectionObserver(callback, options);
Enter fullscreen mode Exit fullscreen mode

The new keyword indicates we’re creating a new instance, which receives callback and options as arguments (we’ll define these in a minute). We’ve stored the result in the variable observer so we can use it other places in our code.

Once the observer is initialized, we can tell it what elements we want it to watch. In this case, we want it to watch any elements with the class .animate and add the .animated class to them if they’re visible. To watch a single element, we would call observer.observe() and pass in our element. However, we want to watch multiple elements, so we’ll need to use a forEach loop like this:

const observer = new IntersectionObserver(callback, options);

const animatedElements = document.querySelectorAll(".animate");

animatedElements.forEach(element => observer.observe(element));
Enter fullscreen mode Exit fullscreen mode

We’ve initialized our observer and told it what elements to pass, however we still need to define our options and our callback before everything will work. Let’s define options first:

const options = {
  root: null,
  rootMargin: "0px",
  threshold: 0.3,
};

const observer = new IntersectionObserver(callback, options);

const animatedElements = document.querySelectorAll(".animate");

animatedElements.forEach(element => observer.observe(element));
Enter fullscreen mode Exit fullscreen mode

Options is an object that we pass to observer that tells it the root element to observe (in this case null since we want to watch the whole document), the rootMargin (0px in this case), and the threshold where an element should be considered visible (in this case 0.3, meaning an element will be considered visible if at least 30% of it is in the viewport). You can play around with different threshold values to suit your needs.

Next, we need to define our callback function, which will be invoked every time the observer finds a matching element. The callback function receives two parameters by default: entries and observer . In our case, we’ll be interested in entries which is an array of elements that match the ones we told observer to watch (any element with a .animate class in our case).

const options = {
  root: null,
  rootMargin: "0px",
  threshold: 0.3,
};

const callback = (entries, observer) => {
  console.log(entries);
}

const observer = new IntersectionObserver(callback, options);

const animatedElements = document.querySelectorAll(".animate");

animatedElements.forEach(element => observer.observe(element));
Enter fullscreen mode Exit fullscreen mode

For the moment, we’ll just log entries to the console so we can make sure everything is working, and to take a look at what sort of information entries will make available to us.

From the console.log we can see that entries returns an array of IntersectionObserverEntry items, each of which has an isIntersecting property (true if the element is past the viewport threshold, false if not), and a target property which gives us a reference to the specific HTML element we may want to work with.

With these two properties, we can modify our callback function to loop through the entries list, check if an entry is in the viewport, and do something with that element (add the .animated class in our case). Here’s what the final function looks like:

const options = {
  root: null,
  rootMargin: "0px",
  threshold: 0.3,
};

const callback = (entries, observer) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && !entry.target.classList.contains("animated") {
      entry.target.classList.add("animated");
    }
  })
}

const observer = new IntersectionObserver(callback, options);

const animatedElements = document.querySelectorAll(".animate");

animatedElements.forEach(element => observer.observe(element));
Enter fullscreen mode Exit fullscreen mode

Since we don’t want to add the class every time an element goes in our out of the viewport, we use an if check to make sure the entry is in the viewport (isIntersecting) and that the element’s classList doesn’t already contain the “animated” class.

Conclusion

If you’ve been following along, you should now be able to scroll through your page and see the elements animate in as they come into view (or feel free to check out my Codepen with the final result here).

Best of all, the JavaScript and CSS to achieve this effect are relatively lightweight, and since the JavaScript is just adding a class when an element scrolls into view, there’s a lot of flexibility for the type of effects you can achieve with just this little bit of code.

Top comments (0)