nray.dev home page

How to Create Performant Scroll Animations in React

Performant web animations can be tricky to get right. Coupling these animations with a high-frequency critical event like scrolling increases the stakes. Done well, they can enhance the user's experience. Done poorly, they can frustrate users and make your site unusable.

In this post, I will show you how to avoid the common pitfalls and incorporate performant animations that can captivate your users. Let's get started!

Staying off the main thread

If you want animations to have the best chance of looking smooth to your users, you have to make them run somewhere free from interference. Smooth animations achieve consistent and rapid frame production. Interference can prevent the browser from producing frames as fast as the screen's refresh rate, leading to janky animations. For example, if the screen refreshes at a rate of 60 times a second (typical for most monitors), the browser must produce each frame within 16.667 milliseconds for the animation to look smooth. Higher-end 120 hertz monitors cut this budget in half and require frames made in 8.333 milliseconds. The animation will look slow and sluggish if the browser doesn't meet this deadline.

To make matters worse, there are many potential sources of interference. The browser's parsing of HTML sent from the server when the page is loading can cause interference. The execution of JavaScript can interfere. The browser's rendering steps, like when it computes the styles of elements, determines an element's geometry and position, or creates paint records, are all sources of jank. And that's not everything!

All the sources of interference mentioned previously run on the main thread of the browser's renderer process. The main thread is a dangerous place. We want to avoid running our animations here because the competition is too fierce. Only one thing can execute on the main thread at a time, and the risk is too high that it will be something other than our animation.

The best place to run animations is where none of these competing events happen. Luckily, another thread, the compositor thread, has much less stuff. As a result, it is not as prone to jank. There is a slight catch, though. The CSS properties opacity and transform are the only compositor thread-friendly properties. Consequently, attempting to animate any other property will make your animation run on the main thread, where it will be prone to jank. Still, you might be surprised how much you can achieve with only these properties.

Detecting when an element has entered the viewport

To trigger animations when a user scrolls, we need to detect when the element we'd like to animate has entered the viewport. Once it has sufficiently entered the viewport, we want the animation to begin. In the past, we might have achieved this with a scroll event listener:

const animatable = document.querySelector(".animatable");
const handler = () => {
// Check the position of the element relative to the viewport.
const position = animatable.getBoundingClientRect();
// Check if at least 1 pixel of the element is within the viewport.
if (
(position.top > 0 && position.top <= window.innerHeight) ||
(position.bottom > 0 && position.bottom <= window.innerHeight)
) {
// Element is within viewport so start the animation by removing
// the class that controls its starting position.
animatable.classList.remove("animatable--initial");
// Remove event listener once the animation has started since we
// don't need it anymore.
window.removeEventListener("scroll", handler);
}
};
window.addEventListener("scroll", handler);

However, this approach can cause a couple of problems:

  • We have to check the element's position relative to the viewport on each scroll event. This frequent querying occurs on the main thread, which has potential to cause jank to other areas of the user experience (e.g. scrolling).

  • Relatedly, calling element.getBoundingClientRect() can be an expensive call. It can make the browser perform expensive style recalc and layout operations which ties up the main thread even more.

It would be ideal if the browser could fire our callback when it has detected that our element of interest has entered the viewport instead of being forced to query this information on every scroll event. Fortunately, there is a way with the Intersection Observer API.

Using Intersection Observer to trigger scroll-down animations

Use the Intersection Observer API to detect when an element enters or leaves the viewport without the expensive querying that a scroll event listener might cause. Instead, Intersection Observer detects intersection changes in a way that stay off the main thread!

const animatable = document.querySelector(".animatable");
const options = {
// Fire callback as soon as little as one pixel is visible in the
// viewport.
threshold: 0,
};
const callback = () => {
// Change in the intersection of one or more observed elements.
};
const observer = new IntersectionObserver(callback, options);
observer.observe(animatable);

The callback passed to Intersection Observer will be called whenever intersection changes are associated with the observed element. It's important to note that the code in this callback will take place on the main thread, so we shouldn't do anything costly there. Writing efficient code in this callback is fairly easy, however, because the information that the callback's entries param contains is usually all we need to determine whether we should trigger the animation. Using an Intersection Observer instead of a scroll event listener in the previous example would look like this:

const animatable = document.querySelector(".animatable");
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// Check if at least 1 pixel of the element is within the
// viewport.
if (entry.isIntersecting) {
// Element is within viewport so start the animation by removing
// the class that controls its starting position.
animatable.classList.remove("animatable--initial");
// Stop observing element once the animation has started since
// we don't need it anymore.
observer.disconnect();
}
});
});
// Start observing element.
observer.observe(animatable);

Creating scroll animations in React with Intersection Observer

Now with all the theory out of the way, let's see how we could build scroll-triggered animations using Intersection Observer in React and Typescript. We'll take advantage of React Hooks to create something simple and reusable.

Step 1: Create a new hook

First, following the conventions of React Hooks, let's create a new file with the following boilerplate.

useIntersectionObserver.ts
import { useEffect, useRef, useState } from "react";
type ReturnType = [
(element: Element | null) => void,
IntersectionObserverEntry | null,
];
interface Options extends IntersectionObserverInit {
/**
* Only execute Intersection Observer callback once while the
* element is intersected. Use this if you only want to run an
* animation once, for example.
*/
executeOnce?: boolean;
}
/**
* A React Hook used to observe intersection changes with
* elements.
*
* @param options
*/
function useIntersectionObserver({
root = null,
rootMargin = "0%",
threshold = 0,
executeOnce = false,
}: Options): ReturnType {}
export default useIntersectionObserver;

Step 2: Add state and a ref to the element

We need to add a few bits of state to our hook. First, we need to store a reference to the element we want to observe. We take advantage of React's support for callback refs by passing the setRef callback to the client of the hook. We also store the intersection observer entry which will be null when the component first renders. Finally, a ref stores whether the callback has already been triggered while the element intersected.

useIntersectionObserver.ts
function useIntersectionObserver({
root = null,
rootMargin = "0%",
threshold = 0,
executeOnce = false,
}: Options): ReturnType {
const [ref, setRef] = useState<Element | null>(null);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(
null,
);
const hasExecuted = useRef(false);
return [setRef, entry];
}
export default useIntersectionObserver;

Step 3: Create IntersectionObserver instance inside useEffect hook

To create our instance of IntersectionObserver and start observing our element, we need to add a useEffect hook. In the callback passed to the IntersectionObserver constructor, we'll set the entry state. Intersection changes will trigger the callback. We return a cleanup function from the effect that will run when any of the dependencies passed as a second argument to useEffect changes or if the component unmounts.

useIntersectionObserver.ts
function useIntersectionObserver({
root = null,
rootMargin = "0%",
threshold = 0,
executeOnce = false,
}: Options): ReturnType {
const [ref, setRef] = useState<Element | null>(null);
const [entry, setEntry] = useState<IntersectionObserverEntry | null>(
null,
);
const hasExecuted = useRef(false);
useEffect(
() => {
// Return early if the callback has already been executed while
// the element was intersected, if we don't have a reference to
// the element yet, if the browser doesn't support
// IntersectionObserver or if the callback has already executed
// while the element was intersected.
if (
!ref ||
!window.IntersectionObserver ||
(executeOnce && hasExecuted.current)
) {
return;
}
// Create new Intersection Observer instance where we'll set the
// entry state when intersection changes are detected.
const observer = new IntersectionObserver(
([entry]) => {
setEntry(entry);
if (entry.isIntersecting && executeOnce) {
observer.disconnect();
hasExecuted.current = true;
}
},
{ root, rootMargin, threshold },
);
// Start observing element.
observer.observe(ref);
// Cleanup function;
return () => {
observer.disconnect();
};
},
// Avoid excessive re-renders by converting threshold to a string
// if it is an array. eslint-disable-next-line
// react-hooks/exhaustive-deps
[root, rootMargin, JSON.stringify(threshold), ref, executeOnce],
);
return [setRef, entry];
}
export default useIntersectionObserver;

Clients can then use this hook like the following example:

App.tsx
import useIntersectionObserver from "./useIntersectionObserver";
import "./styles.css";
interface AnimatableProps {
children: React.ReactNode;
}
/**
* Animates an element when at least 35% of the element is within the
* viewport including its top.
*/
function Animatable({ children }: AnimatableProps) {
const [ref, entry] = useIntersectionObserver({
// Trigger callback when at least 35% of the element is visible in
// the viewport.
threshold: 0.35,
// Only execute animation once.
executeOnce: true,
});
// Add initial transform/opacity classes if the entry is not
// intersecting.
const addInitial = !entry?.isIntersecting;
return (
<div
ref={ref}
className={`animatable ${
addInitial ? "animatable--initial" : ""
}`.trim()}
>
{children}
</div>
);
}
export default function App() {
const animatables = [];
for (let i = 0; i < 20; i++) {
animatables.push(<Animatable key={i}>{i}</Animatable>);
}
return (
<section>
<div className="container">{animatables}</div>
</section>
);
}

Step 4: Animate responsibly

When adding any feature to a website, it's essential to consider its accessibility implications. For example, not everyone likes animations, and many people have vestibular motion disorders where animations can cause harm. Therefore, users should be allowed to opt out of any animation we add to a website.

One relatively easy way we can achieve this is to use the prefers-reduced-motion media query to only enable the animation when the user has enabled the "reduce motion" setting in their operating system. By using the media query, the end point of the animation renders, and the transition will be inert when the user has enabled the setting.

I also like to disable scroll-triggered animations on mobile devices since they are much more prone to jank due to generally having less powerful hardware. Unfortunately, there isn't a media query explicitly targeting mobile devices, but we can use the viewport's width as a sufficient proxy. Therefore, we will only enable the animation if the viewport exceeds a minimum width.

styles.css
.animatable {
display: flex;
align-items: center;
padding-left: 48px;
padding-right: 48px;
height: 500px;
justify-content: center;
background: #cbd5e1;
color: #0f172a;
font-size: 24px;
border-radius: 16px;
}
/**
* Only enable animations for devices with relatively large viewport
* widths which serves as a proxy for devices with more power and for
* users who haven't set the "prefers reduced motion" setting on their
* OS.
*/
@media (prefers-reduced-motion: no-preference) and (min-width: 640px) {
.animatable {
transition-duration: 500ms;
/**
* Limit transitions to compositor thread-friendly properties for
* the best performance.
*/
transition-property: transform, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
}
@media (prefers-reduced-motion: no-preference) and (min-width: 640px) {
.animatable--initial {
transform: translateX(33%);
opacity: 0.6;
}
}

Check out the demo, but keep in mind that you won't see the animation if your viewport is less than 640px due to the media query styles that we added above.