Master the Intersection Observer API - Single Page Navigation (Part 1)

Prefer to watch, rather than read? Here's the video version.

It’s no secret that modern websites rely heavily on scroll events. Scrolling can trigger lazy-loading of images and data, initiate animations, support infinite loading of content, and so much more. Unfortunately these scroll events are both unreliable and resource intensive. This causes issues in implementation and often results in poor browser performance.

The Intersection Observer API was created as a solution to these problems, and a new way to handle scroll events. Simply put, the API provides users a way to observe given elements and monitor changes in their intersection with a given ancestor element, or the viewport itself.

What’s the problem with the current implementation? Consider a typical site these days. There are many scroll events going on. The ads on the site load when scrolled into view, new content loads when the bottom of the page is reached, elements animate on from time to time, and images are lazily loaded as the user reaches them. These scroll events rely upon countless loops calling performance-intensive methods like Element.getBoundingClientRect() to get the necessary positioning information.

When these methods run, it’s all on the main thread, which means an issue with one causes a problem for everything. The Intersection Observer API passes off management of intersection events to the browser by using callback functions tied to the intersection status of specific elements. The browser can manage these events more effectively, optimizing for performance.

One really important caveat here is noting the current level of browser support. While Chrome, Edge, and Firefox have implemented the API, Safari is the missing player. That’s a pretty big deal, but they are actively working on adding support. That means it’s really not a bad time to start getting familiar with Intersection Observer. Until Safari adds support (and for older browsers), the polyfill is pretty sufficient. I’ll cover that more towards the end.

Concepts & Basic Usage

To fully understand why the Intersection Observer API is so much better for performance, let’s start with a look at the basics.

IntersectionObserver Definitions

A few key terms are used to define any instance of an Intersection Observer. The root is the element which waits for an object to intersect it. By default, this is the browser viewport, but any valid element may be used.

While the root element is the basis of a single IntersectionObserver, the observer can monitor many different targets. The target may also be any valid element, and the observer fires a callback function when any target intersects with the root element. Root Target Intersection GIF

Basic Usage

Setting up a simple IntersectionObserver is straightforward. First, call the IntersectionObserver constructor. Pass a callback function and desired options to the constructor function:

const options = {
	root: document.querySelector('#viewport'),
	rootMargin: '0px',
	threshold: 1.0
};

const observer = new IntersectionObserver(callback, options);

As seen above, a few options are available to set in the constructor:

root

The root is the element which is used to check for intersections of the target element. This option accepts any valid element, though it’s important that the root element be an ancestor of the target element for this to work. If a root isn’t specified (or null is the provided value), the browser viewport becomes the root.

rootMargin

The rootMargin value is used to grow or shrink the size of the root element. Values are passed in a string, with a CSS-like format. A single value can be provided, or a string of multiple values to define specific sides (e.g. '10px 11% -10px 25px).

threshold

Last, the threshold option specifies the minimum amount the target element must be intersecting the root for the callback function to fire. Values are floating point from 0.0 - 1.0, so a 75% intersection ratio would be 0.75. If you wish to fire the callback at multiple points, the option also accepts an array of values, e.g. ~[0.33, 0.66, 1.0]~.

Once the IntersectionObserver instance is created, all that’s left is to provide one or more target elements for observation:

const target = document.querySelector('#target');
observer.observe(target);

From here, the callback function will fire anytime the target(s) meet the threshold for intersection.

const callback = function(entries, observer) {
	entries.forEach((entry) => {
		// do stuff here
	});
}

Calculation Intersection Observer Intersections

It’s important to understand how intersections are calculated. First, the Intersection Observer API considers everything to be a rectangle for the sake of this calculation. These rectangles are calculated to be the smallest they can possibly be, while still containing all target content.

Bounding box outlines

Beyond the bounding boxes, consider any adjustments to the bounding box of the root element based on rootMargin values. These can pad or decrease the root size.

Root Margin Calculations

Finally, it’s crucial to understand that unlike traditional scroll events, Intersection Observer isn’t polling constantly for every single change in intersection. Instead, the callback is only called when the provided threshold is reached (approximately). If multiple checks are required, simply provide multiple thresholds.

Demo 1 - Animated Boxes

The first small project is a simple way to see the Intersection Observer API in action.

See the Pen Intersection Observer #1 - Transforming Boxes by Heather Weaver (@heatherthedev) on CodePen.

Scrolling down, a series of boxes appear. An IntersectionObserver instance is set up to monitor those 3 boxes (targets). When they have fully entered the viewport (root), a class is applied to the target, triggering a CSS animation.

To follow along with the demo, a starter template is available in Codepen. For those who prefer to work locally, a simple static project generator with a Gulp script to compile & live reload is available on Github. Both these options support the Pug, Stylus, and ES6 seen below.

HTML

The markup for the page is quite simple. Each section is a div, containing a box. Each box has a class defining the animation style, and a span listing the animation style. Eventually, a class will be applied to the box with Javascript to trigger the animation.

.slide.slide--intro
	h1 Intersection Observer Demo
	p (scroll please)

.slide
	.box.box--spin
		span Spin
	
.slide
	.box.box--grow
		span Grow
	
.slide
	.box.box--move-right
		span Move Right

CSS

For the purposes of this demo, the styles are also quite simple. First, we’ll style the slides to center content and take up the full screen.

.slide
	display flex
	align-items center
	justify-content center

	min-height 100vh

.slide--intro
	flex-direction column

Each box has some shared styles to control size, positioning, and text. Note the transition style, used for the animation classes below.

.box
	display flex
	align-items center
	justify-content center
	
	width 150px
	height 150px
	
	color white
	text-align center
	
	background-color DeepPink
	transition transform 1s ease-in

Finally, each box has a unique transform property which is transitioned to when the .box--visible class is applied:

.box--spin.box--visible
	transform rotate(1080deg)
	
.box--grow.box--visible
	transform scale(1.5)
	
.box--move-right.box--visible
	transform translateX(50px)

Javascript

Now it’s time for the exciting stuff - getting the Intersection Observer set up. That means first creating the observer by setting options, and then calling the IntersectionObserver constructor. In this demo, the threshold option is set to 1.0, to make sure the entire box is on screen before beginning the animation. No adjustments are made to the root, it simply defaults to the browser viewport with standard margins.

// create the observer
const options = {
	threshold: 1.0,
};

const observer = new IntersectionObserver(scrollImations, options);

From there, find and observe all the desired targets. In this case, that means all the boxes.

// target the elements to be observed
const boxes = document.querySelectorAll('.box');

boxes.forEach((box) => {
	observer.observe(box);
});

Defining the callback function that was passed to the IntersectionObserver above is the final step. Here, the callback function receives an array of entries. The array is looped through, and each entry is checked to verify that it is intersecting, and that it is fully visible. Fully visible boxes have a class applied, and all others have it removed — this triggers the CSS animations defined earlier.

// callback function to do animations
const scrollImations = (entries, observer) => {
	entries.forEach((entry) => {
		// only do animation if the element is fully on screen
		if(entry.isIntersecting && entry.intersectionRatio == 1) {
			entry.target.classList.add('box--visible');
		} else {
			entry.target.classList.remove('box--visible');
		}
	});
}

At this point, scrolling down the page should trigger all box animations. Try experimenting with different Intersection Observer options, and definitely take a closer look at the full entry and observer data in the callback function via the console.

Demo 2 - Onpage Navigation

While these demos aren’t digging into the real meat of the Intersection Observer API and its many applications, this demo is definitely more practical. Intersection Observer is great for creating those single page navbars where the current section is highlighted, and it updates on scroll.

See the Pen Intersection Observer #2 - Onpage Navigation by Heather Weaver (@heatherthedev) on CodePen.

For this one, the same Codepen starter template, or Github download for local development should work.

HTML

The markup here is quite simple. First, the nav contains the menu item which would link to each section. The active class is applied to a link in order to highlight it as needed.

nav
	ul
		li
			a(href='#one').active One
		li
			a(href='#two') Two
		li
			a(href='#three') Three
		li
			a(href='#four') Four
		li
			a(href='#five') Five

Below that, each slide is simply a section. An id is added to each section to make sure it can properly communicate the section number to the navigation items.

section#one
	p Slide One

section#two
	p Slide Two

section#three
	p Slide Three
	
section#four
	p Slide Four
	
section#five
	p Slide Five

CSS

To style this page, start with the navbar. It’ll be a fixed element, always present at the top of the screen.

nav
	position fixed
	
	width 100%
	top 0
	left 0
	right 0
	
	background-color white

Within the navigation, the items require some styling to display in a row with appropriate spacing. Flexbox is used here to evenly space the list items in the given area.

nav
	[...]

	ul
		list-style-type none
		display flex
		align-items center
		justify-content space-around
		
		width 100%
		max-width 800px
		height 50px
		
		margin 0 auto
		padding 0

	li
		display inline-block

		padding 5px

The final part of the navigation element is to style the links themselves. There’s a transition on the background-color, so it fades in on hover, or when the active class is applied on scroll.

nav
	[...]

	a
		display block

		height 40px
		
		padding 0 20px
		
		line-height 40px
		text-decoration none
		text-transform uppercase
		color #323232
		font-weight bold
		
		border-radius 4px
		
		transition background-color 0.3s ease-in
		
		&:hover
		&:active
		&:focus
			background-color rgba(#B8D6A8, 0.5)
		
		&.active
			background-color rgba(#B8D6A8, 0.5)

Now that the navigation is styled, the slides also require some simple styling. Each slide takes up the full screen height (at least), and uses flexbox to center all content. I also applied a simple color scheme I grabbed from Adobe Color.

section
	display flex
	align-items center
	justify-content center
	
	min-height 100vh
	
p
	text-align center
	color white
	font-size 3.5em
	font-weight bold
	text-transform uppercase
	
#one
	background-color #6CA392
	
#two
	background-color #FFA58C
	
#three
	background-color #FF4F30
	
#four
	background-color #576B51
	
#five
	background-color #392A1B

Javascript

The final portion of this demo requires setting up the Intersection Observer. First, set up the observer and target all sections for observation.

// init the observer
const options = {
	threshold: 0.45
}

const observer = new IntersectionObserver(changeNav, options);

// target the elements to be observed
const sections = document.querySelectorAll('section');
sections.forEach((section) => {
	observer.observe(section);
});

The changeNav function provided as a callback for the observer must also be defined. This callback simply verifies the section is on screen enough, and then applies the active class to the appropriate navigation item.

// simple function to use for callback in the intersection observer
const changeNav = (entries, observer) => {
	entries.forEach((entry) => {
		// verify the element is intersecting
		if(entry.isIntersecting && entry.intersectionRatio >= 0.55) {
			// remove old active class
			document.querySelector('.active').classList.remove('active');
			// get id of the intersecting section
			var id = entry.target.getAttribute('id');
			// find matching link & add appropriate class
			var newLink = document.querySelector(`[href="#${id}"]`).classList.add('active');
		}
	});
}

No navigation is complete without the actual links working. Though this doesn’t have anything to do with the Intersection Observer API, here is some simple code to listen for clicks and scroll to the appropriate section.

I’m using zenscroll for this, because smooth-scrolling is still a bit of a pain to implement from scratch. This library is 1.4kb, which is lightweight enough for me. Zenscroll works out of the box here because the links are configured to match the ids of each section. Just load the script via CDN and it’ll work, or set it up manually for a more granular configuration.

Browser Support & The Polyfill

As mentioned at the beginning of the article, webkit (and therefore Safari) still doesn’t support the Intersection Observer API, though it is working in Chrome, Firefox and Edge. Luckily, the polyfill does a great job of filling in those gaps. The official code and documentation is available on the W3C GitHub.

The easiest way to use the Polyfill is to use polyfill.io. Polyfill loads just the specified polyfills, and only when the browser requires it. This helps keep the page weight to a minimum, but enables the polyfill to work with no further configuration required. Just use the following:

<script src="https://polyfill.io/v2/polyfill.min.js?features=IntersectionObserver"></script>

Once the polyfill is loaded, the demos work automatically in Safari, IE7+ and more.

In Conclusion

As you can see, the Intersection Observer API is pretty simple to use and works out of the box. Even though a polyfill is still required, as the browser implementation continues, this API is going to be a boon to front-end performance.

This article only covers a basic implementation, but upcoming articles will dive deeper into performance and animation. These articles will cover topics like lazy loading, greensock integration, and the new performance concerns when it comes to using the Intersection Observer API. Want to be notified when new content is posted? Subscribe to my newsletter below.