Journalists, non-profits and activists increasingly turn to scroll-based animations in order to tell compelling stories. These digital projects — a mixture of text, imagery and video — pose unique performance and usability challenges for frontend developers. For this project, the trickiest problem I encountered was how to reconstruct the timeline. It had almost 50 "frames" and the transitions between each frame needed to be snappy and fluid.
There were too many animations to run them directly off of the scroll observer. That's because the scroll observer may fire dozens of times each second. Usually, when there's an event like this that fires very frequently, we debounce the callback functions, so that we only run things once the event has stopped firing. You've probably experienced this when searching on a website. Often, the search doesn't start until you've stopped typing your search query for a moment.
With scrolling, however, the interface needs to feel immediately responsive in order to give the user the illusion that they control the screen like a physical device. In order to achieve this, it's important that transitions between frames fire as soon as the user scrolls into position. This creates some challenges when using a modern, reactive JavaScript framework like Vue or React.
Every time the user scrolls, the progress value will increase and Vue will fire three callback functions: one for each v-if statement. This will create a performance problem because the scroll progress fires dozens of times per second. With 50 frames, more than 1000 callbacks might be fired every second, hammering the CPU and causing the animations to stutter.
To solve this, I extracted the frames into a set of arrays. I then wrote a single callback function that would use these arrays to track which frames had been fired and which were still pending. By doing this, I could ensure that the callback function, which is fired often, remained as lightweight as possible. Vue's full reactivity only kicks in when a frame is changed.
The pending and fired arrays are important to make this work. Without them, the watch() callback would loop through an array of 50 items every time it was called. By shifting frames into the pending and fired arrays, the callback only loops through each array until it finds the first frame that is out of scope. This is almost always the first item in the array. Most of the times that the callback is fired, it will stop at the first item in the array. That means that this technique can scale to hundreds or thousands of frames.
In the following video, I've logged when the user's scroll changes and when a frame change is triggered. This illustrates how much rarer it is to trigger a frame change. By keeping expensive computations off of the scroll observer's callback, I was able to keep the timeline's animations smooth and responsive to user input.
Tracking the scroll position of almost 50 frames was annoying. Each frame had to be timed to a specific window in the scroll progress between 0.0 and 1.0. Whenever a frame was added or removed, these values would have to be adjusted.
To solve this, I created a simple abstraction that allowed me to add and remove frames, give each one a duration, and automatically calculate their positions on the scroll progress.
This allowed me to make use of named frames in Vue components, so that the code was easy to read and maintain.
This was a fun project and I'd love to work on similar stories. Please get in touch if you think I can help you solve performance problems or pull off smooth animated transitions in your digital interactives and stories.