How to implement scrollytelling with six different libraries

Scrollytelling is the best (or the worst depending on your point of view). We love it here at The Pudding. But creating a scroll-driven story is hardly a standardized practice, and there are many libraries out there that can help make it happen. Check out my follow-up post, where we look at mobile solutions.

“Scrollytelling” is when content (e.g., a graphic) is revealed or changed as the user scrolls. It does not alter scroll behavior, but simply monitors it. As Martha Stewart would say, “It’s a good thing.” Do not confuse this will scrolljacking, which manipulates the browser’s scroll mechanics with JavaScript, which is generally considered bad practice. Here are a few examples of the good stuff in action:

In this post, I look at how to tackle a simple scroll-driven chart using six different libraries and share my thoughts on each implementation. It is inspired by Lisa Charlotte Rost’s great post comparing charting libraries.

I'm not going to get into the different types of scrollytelling, or debate the serious moral and ethical implications of the practice. I defer to these posts for scrollytelling primers.

We want to make something like this:

There are two goals here:
1. Use the "scroll-to-trigger" pattern where the trigger element (in our case a block of text) tells the chart to update to a new state.
2. We want the chart to stay fixed while the text moves and have it snap back into place as it enters and exits, because often the scrollytelling portion is not the entire story.

Disclaimer: this post does not cover responsive solutions (though you should do it, and most libraries make it pretty painless). If you have the audacity to resize your browser, refresh the page. For mobile and responsive solutions, check out my follow-up post,

Update (November 2017): I created my own scrollytelling library, scrollama.js. The goal of this library is to provide a simple interface for creating scroll-driven interactives. Scrollama is focused on perfomance by using IntersectionObserver in favor of scroll events. Check it out.

#1 - Waypoints

Waypoints is a very solid choice. It has been around for a while, and supports both vanilla JS and jQuery integration. It has an easy to understand API, and good documentation. It has a lot of add-ons, like a debugger extension to reduce some head-scratching, inview detection, and sticky elements. Below is the core code used to implement this library. See the demo for the full code with comments.

Demo →


...

var waypoints = triggerEls.map(function(el) {
	var step = +el.getAttribute('data-step')

	return new Waypoint({
		element: el,
		handler: function(direction) {
			var nextStep = direction === 'down' ? step : Math.max(0, step - 1)
			graphic.update(nextStep)
		},
		offset: '50%',
	})
})

var enterWaypoint = new Waypoint({
	element: graphicEl,
	handler: function(direction) {
		var fixed = direction === 'down'
		var bottom = false
		toggle(fixed, bottom)
	},
})

var exitWaypoint = new Waypoint({
	element: graphicEl,
	handler: function(direction) {
		var fixed = direction === 'up'
		var bottom = !fixed
		toggle(fixed, bottom)
	},
	offset: 'bottom-in-view',
})

...
				

#2 - ScrollStory

ScrollStory is a jQuery-based library used for some projects at The New York Times. It has a super clear API and supports tons of options. The two big drawbacks that I found were that it depends on jQuery (which is not a problem if you use it by default) and it requires some additional code to get the fixed graphic part working properly. If those two things are not important to you, I recommend exploring this one since it is setup to handle a variety of use cases.

Demo →


...

var handleItemFocus = function(event, item) {
	var step = item.data.step
	graphic.update(step)
}	

var handleContainerScroll = function(event) {
	var bottom = false
	var fixed = false

	var bb = $graphicEl[0].getBoundingClientRect()
	var bottomFromTop = bb.bottom - viewportHeight

	if (bb.top < 0 && bottomFromTop > 0) {
		bottom = false
		fixed = true
	} else if (bb.top < 0 && bottomFromTop < 0) {
		bottom = true
		fixed = false
	}

	toggle(fixed, bottom)
}

$graphicEl.scrollStory({
	contentSelector: '.trigger',
	triggerOffset: halfViewportHeight,
	itemfocus: handleItemFocus,
	containerscroll: handleContainerScroll,
})

...
				

#3 - ScrollMagic

ScrollMagic is based on Superscrollorama, a previously popular library. Like Waypoints, it is quite robust, well-documented, and totally customizable. It has a great add-on for debugging. It also has no dependencies, which is nice. I do sometimes notice that the scroll events get a bit janky and won’t fire immediately, which is a built-in function the library has to deal with the scroll events.

Demo →


...

var controller = new ScrollMagic.Controller()

var scenes = triggerEls.map(function(el) {
	var step = +el.getAttribute('data-step')

	var scene = new ScrollMagic.Scene({
		triggerElement: el,
		triggerHook: 'onCenter',
	})

	scene
		.on('enter', function(event) {
			graphic.update(step)
		})
		.on('leave', function(event) {
			var nextStep = Math.max(0, step - 1)
			graphic.update(nextStep)
		})
	
	scene.addTo(controller)
})

var enterExitScene = new ScrollMagic.Scene({
	triggerElement: graphicEl,
	triggerHook: '0',
	duration: graphicEl.offsetHeight - viewportHeight,
})

enterExitScene
	.on('enter', function(event) {
		var fixed = true
		var bottom = false
		toggle(fixed, bottom)
	})
	.on('leave', function(event) {
		var fixed = false
		var bottom = event.scrollDirection === 'FORWARD'
		toggle(fixed, bottom)
	})

enterExitScene.addTo(controller)

...
				

#4 - graph-scroll.js

graph-scroll.js is a d3 plugin, so only consider it if you are familiar with and using d3. It is really lightweight, and it was created by Adam Pearce who knows a thing or two about scroll-driven graphics. It is very singularly focused though, so it will likely require you to customize the library a bit to get what you want. That being said, it is only the library that specifically implements the transition to and from a fixed position graphic, which is great.

Demo →


...

d3.graphScroll()
	.container(graphicEl)
	.graph(graphicVisEl)
	.sections(triggerEls)
	.offset(halfViewportHeight)
	.on('active', function(i) {
		graphic.update(i)
	})

...
				

#5 - in-view.js

in-view.js is a great library in general, and I tried to adapt it to my scrollytelling needs. While one of its features is performance optimization, that comes with the downside of only having a single global offset for triggering an enter/exit. I had to use a modified version that allows for creating multiple instances to make it work for this scenario. Also, the offsets are a bit confusing to use in this context. In short, this library is amazingly simple to use for just triggering, but definitely not designed for customized scrollytelling.

Demo →


...

var inviewTrigger = inView()

inviewTrigger.offset({
	top: 0,
	right: 0,
	bottom: halfViewportHeight,
	left: 0,
})

inviewTrigger('.trigger')
	.on('enter', function(el) {
		var step = +el.getAttribute('data-step')
		graphic.update(step)
	})

var inviewTop = inView()

inviewTop.offset({
	top: -999999,
	right: 0,
	bottom: window.innerHeight,
	left: 0,
})

inviewTop('.graphic')
	.on('enter', function(el) {
		var fixed = true
		var bottom = false
		toggle(fixed, bottom)
	})
	.on('exit', function(el) {
		var fixed = false
		var bottom = false
		toggle(fixed, bottom)
	})

var inviewBottom = inView()

inviewBottom.offset({
	top: -999999,
	right: 0,
	bottom: graphicEl.offsetHeight,
	left: 0,
})

inviewBottom('.graphic')
	.on('enter', function(el) {
		var fixed = false
		var bottom = true
		toggle(fixed, bottom)
	})
	.on('exit', function(el) {
		var fixed = true
		var bottom = false
		toggle(fixed, bottom)
	})

...
				

#6 - Roll your own

When in doubt, roll your own. There will be a bit more coding involved (and some fun math!), but you get to familiarize yourself with the core concepts of how scroll-driven libraries are created. It is all custom, so you can have it do whatever you want. You will want to consider performance optimizations like throttling and such.

Demo →


...

var bbTop = 0	
var bbBottom = 0
var height = graphicEl.getBoundingClientRect().height
var prevStep = 0
var currentStep = 0
var numSteps = triggerEls.length

var checkTrigger = function() {
	if (bbTop < viewportHeight && bbBottom > 0) {
		var progress = Math.abs(bbTop - halfViewportHeight) / height * numSteps
		var step = Math.floor(progress)
		currentStep = Math.min(Math.max(step, 0), numSteps - 1)
	}
}

var checkEnterExit = function() {
	var bottomFromTop = bbBottom - viewportHeight
	var bottom
	var fixed

	if (bbTop < 0 && bottomFromTop > 0) {
		bottom = false
		fixed = true
	} else if (bbTop < 0 && bottomFromTop < 0) {
		bottom = true
		fixed = false
	} else {
		bottom = false
		fixed = false
	}
	
	toggle(fixed, bottom)
}

var handleScroll = function() {
	var bb = graphicEl.getBoundingClientRect()
	bbTop = bb.top
	bbBottom = bb.bottom
	
	checkTrigger()
	checkEnterExit()
}

window.addEventListener('scroll', throttle(handleScroll, 50))

var render = function() {
	if (currentStep !== prevStep) {
		prevStep = currentStep
		graphic.update(currentStep)
	}
	
	window.requestAnimationFrame(render)
}
render()

...
				

So what to chose? The best library all depends on your use case, but here are some recommendations based on my findings:

For highly customized stories you will want ScrollMagic or Waypoints.

For the beginner you might want to check out ScrollStory, especially if you lean on jQuery.

For the d3 lover you should explore graph-scroll.js, but be ready to accept the defaults or be ready to tinker.

The full code is available on github. If there are any good libraries out there I missed, give me a shout @russellviz.