nray.dev home page

How to Create Performant Scroll Animations

Avatar
Nicholas Ray
11 min read

There are some websites out in the wild with delightful designs and functionality. One example is Github's logged-out homepage, which has a beautiful design, a 3d interactive globe, and scroll-triggered animations. In fact, their work inspired several of the design elements found on my homepage, including their scroll-triggered animations that beautifully fade in and translate.

Performant web animations can be tricky to get right, though. 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);

You may have noticed that on line 8, we check the boundingClientRect property of the element and wonder how this is any different than the call to getBoundingClientRect() that we cited as potentially problematic and expensive in the previous example that used a scroll event listener. Although the calls look similar, there are a couple of significant differences:

  • Unlike getBoundingClientRect(), calling boundingClientRect returns a cached value. When the intersection change occurs, this value will always be the element's position, which may differ from the element's current position. For example, if you call boundingClientRect in a setTimeout, scroll, and then wait for the setTimeout callback to fire, the position reported by boundingClientRect will be the same as before the scroll took place. Because this call is cached and is not doing a live computation of the element's position, it is relatively fast.
  • It does not cause the browser to perform expensive style recalc and layout operations that calling element.getBoundingClientRect() might incur. Instead, the browser queries this information at a time that is most efficient within the rendering pipeline.

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.js
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.js
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 in 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;
  }
}

Your result should now be similar to the demo below. Keep in mind that you won't see the animation if your viewport is less than 640px due to the media query styles we set.

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>
  );
}

Front-end development languages

Become a better front-end developer

If you liked this post, consider subscribing to my newsletter. I'll let you know when new posts are released and share additional insights that can help your front-end development career. No spam. Unsubscribe at any time.