Building an Accessible Animated Header in React
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
create an element that we can reference as our "intersection trigger"
Set up an observer and define thresholds (essentially our frame rate)
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 ๐ค!