July 14, 2025
Scroll based animations
Scroll based animations isn't a new thing - it's been around for a lot of years. I've done my fair share of both scroll based and mouse move based animations on websites. And I've made some really bad versions of them as well. But I've learned from the bad ones and believe I've found a great balance in when and how to use animations. Over the years we've also been getting better options to do animations in a way that optimizes them for the browsers.
When working with scroll animations I have to approaches:
Working with requestAnimationFrame (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) and do the animations as the user scrolls
Working with the Intersection Observer API (https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and do the animations as an element is intersected in the browser
requestAnimationFrame() method
Using requestAnimationFrame is important to optimize the animations you do when scrolling a site. The animations will run much more smooth and it will be paused if your site running in background tabs, iframes etc. in most browsers. When running the animations when scrolling, the pause functionality isn't that important because we won't do any animations as long as the user does not scroll. This is from the article I linked to above:
The window.requestAnimationFrame() method tells the browser you wish to perform an animation. It requests the browser to call a user-supplied callback function before the next repaint.
The frequency of calls to the callback function will generally match the display refresh rate. The most common refresh rate is 60hz, (60 cycles/frames per second), though 75hz, 120hz, and 144hz are also widely used. requestAnimationFrame() calls are paused in most browsers when running in background tabs or hidden <iframe>s, in order to improve performance and battery life.
Let's have a look at a base setup I work with when doing scroll base animations with the requestAnimationFrame method. First off I've got a scroll.js script that exports a scrollTop variable and a couple of functions to enables me to push multiple functions into the same single handler so we're not ending up with multiple scroll events doing different things. We only want the one scroll event:
export let scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
let ticking = false;
const scrollFunctions = [];
function animate() {
scrollFunctions.forEach(funcRef => funcRef());
ticking = false;
}
function requestTick() {
if (!ticking) {
requestAnimationFrame(animate);
ticking = true;
}
}
function scrollHandler() {
scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
requestTick();
}
export function onScroll(handler, triggerNow = false) {
!scrollFunctions.length ? initScroll() : null;
triggerNow ? handler() : null;
scrollFunctions.push(handler);
}
export function initScroll() {
window.addEventListener('scroll', scrollHandler);
}
initScroll()
The initScroll function is exported even though I rarely call it in my main script as I'm never sure if all pages actually needs the scroll event listener. And there's no reason adding it if it's not necessary. The function simply adds a "scroll" event listener to the window. This leads us to the onScroll function.
onScroll()
The onScroll function is also exported and takes a (scroll) handler and boolean (triggerNow) as parameters. If there's any functions in our scrollFunctions array, we call the initScroll function and if the triggerNow param is true, we fire the passed handler immediately. Last but not least we add our passed handler to our scrollFunctions array.
scrollHandler()
The scrollHandler function is the function getting called each time the user scrolls the site. It's called from the "scroll" event listener we added in the initScroll function. The function updates the scrollTop let which we use to do some calculations of whether or not do animate elements using the script. After updating the scrollTop it calls the requestTick function.
requestTick()
The request tick function simply checks if we're ticking (animating). If we're not we can go ahead and call the animate function via the requestAnimationFrame method and set ticking (animating) to true.
animate()
The animate functions iterates through all our scroll handler functions in the scrollFunctions array, fires them and finally sets ticking to false when done.
Example
I've created a Codepen using the same concept, but with only one scroll handler function directly in the animate function. I'll show you how it could be split out below.
Let's brake it down
So what I created here is a component hiding the text contents step by step when the user scrolls by changing opacity and scaling the content down with the transform attribute.
Let's extract the scrollHandler function we need and call it with the onScroll() function instead:
import { scrollTop, onScroll } from "../utils/scroll";
const stickyContents = document.querySelectorAll('[data-sticky-content]');
const endAnimation = window.innerHeight / 1.5;
const normalize = (val, min, max) => {
return (val - min) / (max - min);
};
stickyContents.forEach((stickyContent) => {
stickyContent.setAttribute('data-top', stickyContent.getBoundingClientRect().top + scrollTop);
});
const scrollHandler = () => {
stickyContents.forEach((stickyContent) => {
const top = stickyContent.getAttribute('data-top');
if (scrollTop >= top) {
const scrolledPastTop = scrollTop - top;
let scrollHeightDiff = normalize(endAnimation - (endAnimation - scrolledPastTop), 0, endAnimation);
scrollHeightDiff < 0 ? 0 : scrollHeightDiff;
let scale = 1 - scrollHeightDiff / 10;
let opacity = 1 - scrollHeightDiff;
scale = scale < 0 ? 0 : scale;
opacity = opacity < 0 ? 0 : opacity;
stickyContent.style.transform = `scale(${scale})`;
stickyContent.style.opacity = opacity;
}
});
};
onScroll(scrollHandler, true);
When I work with HTML elements in JavaScript I like to use data attributes to query my elements. This way I won't be dependent on certain class names and by adding data attributes instead of an extra class name I also indicate that this is not for visual purposes. In this scenario I use the "data-sticky-content" attribute as my content is sticky (sticking to the top) and I want to do something with that sticky content. It could be named much better, but that's not important for now đ
So what I've got in the first part of the script is:
Import of scrollTop and onScroll from my scroll script. We need those to do calculations and to actually add the handler to the scroll functions array that fires when the user scrolls the page
A querySelector that finds all the elements I want to animate when scrolling the page
An "endAnimation" variable indicating for how many px. I want to do the animation. In this case I want to do it in less than the entire screen - so I divide the height by 1.5
A normalize function I use to normalize the distance between the top of the element and the scrollTop to a number between 0 and 1 as I want to animate the opacity property and the scale value of the transform property. Each takes a number between 0 and 1.
Before doing any animations we need to calculate how far the animation elements is from the top of the browser and add them as data attributes on the elements. This way we've always got the numbers on the elements when scrolling and don't need to calculate them each time the page is scrolled.
And now to the fun part - the handler where we actually animate our elements - the scrollHandler. In the handler function we iterate through all our animation elements, check if we've scrolled at least to the top of the element and do the magic. The scrolledPastTop variable tells us how many px. we've scrolled from the top of the element and down. This is the number we normalize to a number between 0 and 1 and use as the value for the opacity and scale attribute values.
As you can see I actually do lot less scaling as it seems a bit to much scaling the entire text content from 1 to 0. I want it to be a lot more subtle so I divide the normalized number by 10 resulting in a much more smooth animation for the scaling.
Actually we could optimize this a bit more as we don't check if we've scrolled entirely past the element we're currently animating. So instead of only asking if scollTop > top we could add a scrollTop < bottom. The bottom being the top + the elements height. But we don't need it for this example to work, but go ahead and optimize your own handler đ
Intersection Observer API
I'm a big fan of the Intersection Observer API as it's a relatively easy way to setup logic that tells us if elements is inside or outside the viewport without having to do calculations of the scroll position and the positions of the elements we want to animate. Aggelos Arvanitakis did a great article comparing the performance of the scroll event listener and the Intersection Observer API back in 2019. I'd recommend you read it if you want to know more about the differences and benefits of the Intersection Observer API. The clear winner was the Intersection Observer API so if you're doing animations that are not too directly tied to the exact scroll position of the page I'd recommend you use the Intersection Observer API. For some animations you'll though, you'll need to use the scroll event as in my first example.
As with the scroll event listener I've got a base setup I use when working with the Intersection Observer API. Let's have a look at the script.
export default function setupIntersect() {
const intersectElements = document.querySelectorAll('[data-intersect]');
let observer;
function onChange(changes) {
changes.forEach(change => {
if (change.intersectionRatio >= 1) {
change.target.classList.add('intersected');
}
});
const allIntersected = Array.from(intersectElements).every(element => element.classList.contains('intersected'));
if (allIntersected) {
observer.disconnect();
}
}
if (intersectElements.length > 0) {
const config = {
root: null,
rootMargin: '-100px',
threshold: [1]
};
observer = new IntersectionObserver(onChange, config);
Array.from(intersectElements).forEach(intersectElement => {
observer.observe(intersectElement);
});
}
}
This script is a bit more simple than the scroll script as I don't need to push a lot of handler functions to an array that needs to be called each time the user scrolls. Using the Intersection Observer API you let the browser do all the math behind the scenes in the most optimized way possible way. There's no event listeners and no handlers getting called all the time the user scrolls. That's why we cant use this for animations like the one in the first example as this is tightly tied to the user scrolling. But we can still do some pretty smooth animations using the Intersection Observer API.
What the script does is that it finds all elements with a "data-intersect" property and sets up an observer for each of them with the specified configuration from the config variable. You can read a lot more about the configuration options at the link in the top of the page.
When a change occurs to an observed element is detected the onChange function is called. If an element has an intersection ratio of 0.1 or above we add the "intersected" css class to the element. We can use this class to do whatever we want to do to the element. Each time the onChange is called we also check if we still have elements that needs to be intersected. If there's none left I disconnect the observer for better performance as I typically don't want to do these observer animations as I scroll up the page again. I only need to do them once. This is not necessary so if you want your animations to keep firing when scrolling up and down the page, you could remove the "intersected" class from the element when the intersection ratio is less than 0.1 and remove the disconnection of the observer.
Let's have a look at a Codepen I made using the Intersection Observer API approach to do scroll based animation.
In this example I use the Intersection Observer API to check if each line in my headlines are intersected. If they are completely intersected the "intersected" class is added and I animate the line to be visible with a smooth cubic-bezier timing function.
What's important to note about this example and my base script is that the threshold is set to 1. You can add more thresholds between 0 and 1, but I want my lines to be animated when every part of the line is inside the viewport as the user can see the entire animation. If you're working with larger elements that might not be possible to show inside the viewport the threshold of 1 won't work. So be aware of working with larger elements - especially on small screens. You might want to adjust the threshold to ensure it'll work on all screen sizes - you don't want to end up having invisible content on your page on phones or small tablet screens.
Conclusion
If you want to make scroll based animations make sure you take the right approach:
If performance is absolutely crucial and your users might be on older and slow devices, consider using only the Intersection Observer API.
If you need animations tightly tied to the scroll position, like in my first example, you need to use the scroll event listener. In this case, make sure you use the requestAnimationFrame method to optimize for the best performance.
That's about it. You don't necessarily need an animation library to do simple smooth animations like this.
If youâve got any questions regarding this article â or others â please donât hesitate to reach out at [email protected]. Youâre also welcome to reach out if you have further tips & tricks you see missing in my articles. Iâll update them and credit you.