Building an Accessible Animated Header in React

Feb 20, 2023

4 min read

My latest venture has recently gone live! It's a web consultancy and you can find more info at gait.dev. I've learned a few things in the process of building this - none more so than that it's much harder to build something production-ready than to whack together a POC! The last 20% of the work really did take 80% of the time - the Pareto principle in action!

TL;DR

The production code for gait.dev is publically available on GitHub.

The full example code can also be found on GitHub.

Back to the Story...

One of the things that fell into that 80% of effort was an animated scrolling header. It's pretty common to see this kind of animated header in content-driven sites and we wanted to have something similar.

There are a few common gotchas that I saw when exploring this:

  • Having an expensive event listener running on any scroll event

  • Not respecting accessibility preferences

  • Not testing across multiple devices

  • Relying too much on JS to do computation rather than CSS

We can avoid these gotchas by...

Using IntersectionObserver to avoid scroll-event listeners

It's pretty common to use a scroll event listener when doing animation related to scrolling. The issue with using that is that you'll run that event listener on every scroll change on your app, regardless of whether your animation is happening or not.

Instead of that, we can utilise the IntersectionObserver API, which lets us trigger a callback when an element intersects with another element (or with the document viewport - which is what we're going to use it for).

We need to

  1. create an element that we can reference as our "intersection trigger"

  2. Set up an observer and define thresholds (essentially our frame rate)

  3. Finally, apply any modifications (like easing functions) on our multiplier before handing it to our CSS

Assuming we have a state called multiplier, a function that returns an array of values called getThresholds, and a ref to an absolutely positioned element (in the case below containerRef) we can use the snippet below to add an IntersectionObserver to our application:


// called anytime the an IntersectionObserver threshold is hit
const handler: IntersectionObserverCallback = ([entry]) => {
    setMultiplier(entry.intersectionRatio);
  }

  useEffect(() => {
    let observer: IntersectionObserver;
    if (!prefersReduceMotion) {
      const options = {
        root: null,
        rootMargin: "0px",
        // threshold is an array of values between 0 and 1 
        threshold: getThresholds(5000),
      }

      observer = new IntersectionObserver(handler, options);
      if (containerRef.current) {
        observer.observe(containerRef.current);
      }
    }

    const unobserveRef = containerRef.current;

    return () => {
      if (unobserveRef) {
        observer?.unobserve(unobserveRef);
      }
    }
  }, [containerRef, prefersReduceMotion]);

Deferring animation calculations to CSS where possible

Give your hard-working JavaScript thread a well-earned break! Not only will you free up your JavaScript for doing everything else it needs to do, but CSS is optimized to handle layout calculations - so let it!

In practice, I've found the best way to do this is to generate a style object with JavaScript, passing a multiplier that is a value between 0 and 1. I then use CSS variables to compute the various parameters I need and perform the calculations:

const computeStyle = (multiplier: number) => ({
      // set up where you start and end your element
      "--startTransformX": `calc((100vw - (${INITIAL_SIZE}px)) / 2)`,
      "--startTransformY": '10vh',
      "--endTransformX": '0px',
      "--endTransformY": '0px',

      // work out the difference between those values
      "--diffX": 'calc(var(--startTransformX) - var(--endTransformX))',
      "--diffY":  'calc(var(--startTransformY) - var(--endTransformY))',

      // calculate how far into the animation you are by using the multiplier
      "--currentTransformX": `calc(var(--diffX) * ${multiplier})`,
      "--currentTransformY": `calc(var(--diffX) * ${multiplier})`,

      // apply the transformation to the element
      "transform": "translate3d(var(--currentTransformX), var(--currentTransformY), 0px)"
   })

Using prefers-reduced-motion media query to determine what animation to run

Many operating systems allow a user to specify that they prefer reduced motion. We can leverage that setting in the browser with the prefers-reduced-motion media query.

This may mean not running your animation when these settings are enabled, or it may mean providing a more simple version of your animation.

You can access the prefers-reduced-motion media query in React with the following snippet:

const [prefersReduceMotion, setPrefersReduceMotion] = useState(() => {
    if (typeof window !== 'undefined') {
      return window?.matchMedia("(prefers-reduced-motion: reduce)")?.matches
    } else {
      return false;
    }
  });

  useEffect(() => {
    const listener = (event: MediaQueryListEvent) => {
      setPrefersReduceMotion(event.matches);
    }
    const reduceMotionQuery = window?.matchMedia("(prefers-reduced-motion: reduce)");

    reduceMotionQuery.addEventListener("change", listener);

    return () => {
      reduceMotionQuery?.removeEventListener("change", listener);
    }
  }, [setPrefersReduceMotion]);

Only running the animation on appropriate devices

This one is self-explanatory - make sure you check that your animation works on smaller devices, it may be that you need to do away with it all together on mobiles (like we did with gait.dev).

Although it can take a bit more time to make sure you're building an animation that is appropriate for all audiences regardless of device size, power or their accessibility needs, It's worth it - after all, the web is for everyone. Happy coding !