How to trigger a CSS animation on scroll

by Nick Ciliak on

This is a step-by-step tutorial for how to create a CSS animation and trigger it on scroll using the Intersection Observer API. If you're new to Intersection Observer, don't let it scare you: it's like a fancy event listener, and it comes together in a few lines of code.

Triggering a CSS animation on scroll is a type of scroll-triggered animation. When people say "on scroll", what they usually mean is "when the element is scrolled into view". This tutorial will cover creating a CSS animation from scratch and applying it when the element has been scrolled into view.

Define a CSS animation using keyframes

Let's start by creating a CSS animation. CSS animations are defined by keyframes.

@keyframes wipe-enter {
0% {
transform: scale(0, .025);
}
50% {
transform: scale(1, .025);
}
}

You can name your animation anything you want. I've defined an animation where the element will grow wide and then tall.

Once your keyframes are defined, you can use the animation on an element by setting the animation-name property to the name of your keyframes. You'll also need to set an animation-duration to specify how long the animation should be and an animation-iteration-count to specify how many times the animation should play.

@media (prefers-reduced-motion: no-preference) {
.square {
animation-name: wipe-enter;
animation-duration: 1s;
animation-iteration-count: infinite;
}
}

I've wrapped the class in the prefers-reduced-motion: no-preference media query. This makes sure that our animation only runs if the user has not enabled the "reduce motion" setting on their operating system.

For this demo, I've created a square element that will be animated. Here's what it looks like:

You can also set up your CSS animation using a single shorthand animation property like this:

@media (prefers-reduced-motion: no-preference) {
.square {
animation: wipe-enter 1s infinite;
}
}

Control the CSS animation with a class

We don't want the animation to play right away. We can control when the animation is played by adding the animation properties to a separate class than the class used to style the element.

.square {
width: 200px;
height: 200px;
background: orange;
// etc...
}

@media (prefers-reduced-motion: no-preference) {
.square-animation {
animation: wipe-enter 1s 1;
}
}

When we add the animation class to the square, the animation will play. Try it:

You'll notice that the animation doesn't play every time you click the button. That's because the CSS animation will only trigger when the square-animation class is added, not removed.

Even though the animation shows the element entering, the element is visible before the animation class is added. When the page first loads, we want the element to be visible for the user even if the JavaScript is blocked or fails.

Add the class when the element is scrolled into view

We've created a CSS animation and can trigger it by adding the class to our element. Instead of adding and removing the class when a button is clicked, we can add it when the element is scrolled into view.

There are three ways to determine when the element is scrolled into view:

  1. Use the Intersection Observer API
  2. Measure the element's offset when the user scrolls
  3. Use a third-party JavaScript library that implements #1 or #2

For a basic scroll-triggered animation like the one we are creating, I recommend using the Intersection Observer API because it requires less code and is better for performance.

The Intersection Observer API lets you keep track of when one element intersects with an another and tells you when that happens. That's perfect for triggering a CSS animation on scroll. We want to know when our square intersects with the viewport. If it is intersecting, that means the square is in view and we should trigger our animation.

If you're familiar with event listeners in JavaScript, you can think of Intersection Observer as a fancy event listener with some extra options. Instead of attaching an event listener to an element, we tell the observer to keep track of the element and where it is on the page.

Let's start by creating an observer and have it keep track of our square:

// Create the observer
const observer = new IntersectionObserver(entries => {
// We will fill in the callback later...
});

// Tell the observer which elements to track
observer.observe(document.querySelector('.square'));

By default, the root element that will be checked for intersection is the browser viewport, so we only need to tell the observer about our square.

When the callback function runs, it gives us an entries array of the target elements we asked it to watch, plus some additional information about them. It will always hand back an array, even if you only observe one element like we are here.

In the callback, we can loop over the array of entries to specify what we want to do. Each entry has an isIntersecting property that will be true or false. If it's true, that means the element is visible within the viewport.

entries.forEach(entry => {
if (entry.isIntersecting) {
// It's visible. Add the animation class here!
}
});

Let's put it all together. Note that entry is the object given to us by the observer and entry.target is the actual element that we are observing, so that's where we should apply the class.

const observer = new IntersectionObserver(entries => {
// Loop over the entries
entries.forEach(entry => {
// If the element is visible
if (entry.isIntersecting) {
// Add the animation class
entry.target.classList.add('square-animation');
}
});
});

observer.observe(document.querySelector('.square'));

Now when the square is intersecting with the viewport, the animation class will be added, which will play the animation.

If you want the animation to play every time the element enters the viewport, you need to remove the animation class when the element is no longer intersecting.

If the element you are animating changes size or position, it can be tricky for the browser to decide if the element is currently in or out of the viewport. It's best to wrap the element you want to animate in an element that won't change size or position and use that one for your observer instead of the element you are animating.

<div class="square-wrapper">
<div class="square"></div>
</div>

Then we observe the square-wrapper element and apply the class to the square like we did previously. If the wrapper is not intersecting, we can remove the class from the square so that the animation will restart next time it is scrolled into view.

const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const square = entry.target.querySelector('.square');

if (entry.isIntersecting) {
square.classList.add('square-animation');
return; // if we added the class, exit the function
}

// We're not intersecting, so remove the class!
square.classList.remove('square-animation');
});
});

observer.observe(document.querySelector('.square-wrapper'));

Now the square will animate every time the wrapper element enters the viewport. I've made the wrapper element visible by giving it a dashed border. Try scrolling up and down over the demo below.

Success! By adding and removing a CSS class when the element enters and leaves the viewport, we've successfully triggered a CSS animation on scroll.

Trigger a CSS transition on scroll

If your animation only has one step, such as fading an element's opacity from 0 to 1, you can use a CSS transition instead of a CSS animation.

The method is essentially the same. Instead of defining keyframes for our animation class, we define the properties we want to transition.

.square {
width: 200px;
height: 200px;
background: teal;
border-radius: 8px;
opacity: 0;
transform: scale(1.2);
}

@media (prefers-reduced-motion: no-preference) {
.square {
transition: opacity 1.5s ease, transform 1.5s ease;
}
}

.square-transition {
opacity: 1;
transform: none;
}

Let's take another square and have it fade in when it enters the viewport. You can see I've already added the square-transition class to the HTML. That's because if the user has JavaScript blocked or it fails to load, they should see the element in its final transitioned state.

<div class="square-wrapper">
<div class="square square-transition"></div>
</div>

This is especially important since we are starting from opacity: 0. If we didn't have the square-transition class setup and the JavaScript fails, the user wouldn't see the element at all! If your transition is to make something fade out, you probably wouldn't want to do this.

When the JavaScript first runs, we can remove the class so it can be added back when we actually want the transition to occur. This should be done outside of the observer, preferably at the start of your JavaScript. Here's the full code:

// Remove the transition class
const square = document.querySelector('.square');
square.classList.remove('square-transition');

// Create the observer, same as before:
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
square.classList.add('square-transition');
return;
}

square.classList.remove('square-transition');
});
});

observer.observe(document.querySelector('.square-wrapper'));

And here's the finished demo. The CSS transition is triggered when the wrapper element is scrolled in and out of view. If the JS fails, the element will still be visible.

As you can see, this technique can be extended in many ways to create a bunch of cool animations. Remember, your animations should be quick, usually less than half a second. I've slowed down the animations in this post so that they are easier to see for learning purposes. If you copy any of this code, be sure to make the animations faster.

If you found this post helpful, check out all of my scroll animation articles.