Scrollytelling with Vue.js

How I managed CPU usage in an interactive, scroll-based story using a reactive framework like Vue.js.
Image of the original print graphic with the digital adaptation visible on multiple devices
I transformed their print-based infographic into a scrolling interactive story that works on all devices.

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.

The timeline required animated transitions between almost 50 different frames.

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.

The following code shows how the user's scroll progress might be used to show elements using Vue's reactivity.
<script setup lang="ts">
const props = defineProps({
	/**
	 * As the user scrolls the page,
	 * the value increases from 0.0
	 * to 1.0
	 */
	progress: {
		type: Number,
		required: true,
	},
})
</script>

<template>
	<div>
		<ul>
			<li v-if="progress > 0.25">
				First Frame
			</li>
			<li v-if="progress > 0.5">
				Second Frame
			</li>
			<li v-if="progress > 0.75">
				Third Frame
			</li>
		</ul>
	</div>
</template>

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.

Simplified example of code that uses "frames" to fire transitions as the user scrolls. See the working code.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'

const props = defineProps({
	progress: {
		type: Number,
		required: true,
	},
})

/**
 * All "frames" of the timeline
 */
const frames: number = [
    0.25,
    0.5,
    0.75,
];

/**
 * All frames not triggered yet
 */
const pending = ref<number[]>(frames.slice())

/**
 * All frames triggered
 */
const fired = ref<number[]>([])

/**
 * The current frame
 */
const frame = computed(() => {
    return fired.value.length
        ? fired.value[fired.value.length - 1]
        : 0
})

/**
 * Add and remove frames from the pending
 * and triggered arrays as the user scrolls
 * down and back up
 */
watch(
    () => props.progress,
    async(newVal, oldVal) => {
        if (newVal > oldVal) {
            for (let frame of pending.value.slice()) {
                if (frame <= newVal) {
                    fired.value.push(frame)
                    pending.value.shift()
                } else {
                    break;
                }
            }
        } else if (newVal < oldVal) {
            for (let trigger of fired.value.slice().reverse()) {
                if (trigger.progress > newVal && !pending.value.includes(trigger)) {
                    pending.value.unshift(trigger)
                    fired.value.pop()
                } else {
                    break;
                }
            }
        }
    }
)
</script>

<template>
	<div>
		<ul>
			<li v-if="frame > 0.25">
				First Frame
			</li>
			<li v-if="frame > 0.5">
				Second Frame
			</li>
			<li v-if="frame > 0.75">
				Third Frame
			</li>
		</ul>
	</div>
</template>

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.

I solved performance problems with the scroll observer by disconnecting Vue's reactivity from the scroll progress callback.

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.

Example code showing how each frame is given a name and duration. See code.
import type { KeyFrame, Trigger } from './types'

let i = 0;
export const KEY_2014_START : number = i++
export const KEY_2014_INTRO : number = i++
export const KEY_2014_INTRO_BUBBLES : number = i++
export const KEY_2014_INEFFECT : number = i++
export const KEY_2014_INEFFECT_HIGHLIGHT : number = i++
export const KEY_2014_DEAD : number = i++
export const KEY_2014_DEAD_HIGHLIGHT : number = i++
export const KEY_2014_YEAREND : number = i++
export const KEY_2014_COLLAPSE : number = i++
export const KEY_2015_START : number = i++
// ...


export const KEYFRAMES : KeyFrame[] = [
  {id: KEY_2014_START, duration: 5},
  {id: KEY_2014_INTRO, duration: 5},
  {id: KEY_2014_INTRO_BUBBLES, duration: 5},
  {id: KEY_2014_INEFFECT, duration: 3},
  {id: KEY_2014_INEFFECT_HIGHLIGHT, duration: 5},
  {id: KEY_2014_DEAD, duration: 3},
  {id: KEY_2014_DEAD_HIGHLIGHT, duration: 5},
  {id: KEY_2014_YEAREND, duration: 10},
  {id: KEY_2014_COLLAPSE, duration: 5},
  {id: KEY_2015_START, duration: 5},
  // ...
]

/**
 * Convert the duration of each keyframe into a trigger point
 *
 * The trigger point is a value between 0 and 1 which represents
 * the percentage of scroll of the timeline when the keyframe
 * should be triggered.
 */
const total = KEYFRAMES.reduce((a, b) => a + b.duration, 0)
let base = 0;

export const TRIGGERS : Trigger[] = KEYFRAMES.map((keyframe, i) => {
  const trigger : Trigger = {
    id: keyframe.id,
    progress: base,
  }
  base += keyframe.duration / total
  return trigger
})

This allowed me to make use of named frames in Vue components, so that the code was easy to read and maintain.

Example Vue component using the named keyframes. See code.
<script setup lang="ts">
import {
	KEY_2014_START,
	KEY_2014_INTRO,
	KEY_2014_INTRO_BUBBLES
} from '../helpers/timelineKeyframes'

const props = defineProps({
	frame: {
		type: Number,
		required: true,
	},
})
</script>

<template>
	<div>
		<ul>
			<li v-if="frame > KEY_2014_START">
				First Frame
			</li>
			<li v-if="frame > KEY_2014_INTRO">
				Second Frame
			</li>
			<li v-if="frame > KEY_2014_INTRO_BUBBLES">
				Third Frame
			</li>
		</ul>
	</div>
</template>

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.