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.
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.
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.
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.