Observing Intersection Observers
As developing for the web has matured and JavaScript engines have become faster, one area remains a significant bottleneck - rendering. It's because of this that so many of the recent development efforts have been focused around rendering, with virtual DOM being one of the more popular examples. In Dojo 2, being aware of these new APIs and approaches has been a priority. But working with a new API has its challenges and the Intersection Observer API is no different.Intersection Observers have the goal of providing "a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport." This will allow sites to lazy-load images and other media, render and remove DOM on demand as we would need for a million-row grid, and provide infinite scrolling as we may see in a social network feed.
But Intersection Observers also solve a bigger problem not immediately obvious to us as developers and outlined in the Web Incubator Community Group's Intersection Observer explanation document: displaying ads. The Interactive Advertising Bureau has a policy that ads must be 50% visible for more than a continuous second. With third-party advertising and page-impression scripts being notorious for contributing to page bloat, this API seems all the more important.
Should we all immediately get to work integrating Intersection Observers into our projects? Unfortunately, there are a number of challenges, inconsistencies, and bugs that currently make it just out of reach and the leading polyfill implementation has a number of outstanding issues. But that does not mean the ability to use Intersection Observers is far off and we hope that by outlining the issues, creating tests, and submitting bug reports, viable use is only a few months away.
How it Works
Intersection Observers work in two parts: an observer instance attached to either a specific node or to the overall viewport and a request to this observer to monitor specific children within its descendants. When the observer is created, it is also provided with a callback that receives one or more intersection entries.
const observer = new IntersectionObserver((entries) = > { entries.forEach(entry = > console.log(entry.target, entry. intersectionRatio)); }); observer.observe(node);
These entries are the heart of the API. Each has information outlining the intersection change and the node whose visibility is currently changing. Three properties are at the core of these entry objects, each providing a dimension of different information:
-
isIntersecting
indicates whether the node assigned to thetarget
property is visible within the observer's root -
intersectionRatio
is a number between 0 and 1 indicating the ratio of the target's view within the observer's root -
intersectionRect
is an object with numbers indicating the size with width and height, and the position with top, left, bottom, and right
Though the API is simple, its use can be complex and unique to each use case. Several examples are provided in the Web Incubator Community Group's Intersection Observer explanation document.
Problem: A Ratio of 0
One of the easiest bugs to encounter is running into an intersection ratio of 0. It is a problem because it can happen both when a node is becoming visible and when a node is no longer visible. In the example below, when scrolling through the rows, you may notice a ratio of 0 appear occasionally. If not, scroll very slowly until the next row appears.
This example is reading the intersectionRatio
property of the IntersectionObserverEntry
passed to the callback. It seems like a logical property to use to detect an intersection - after all, wouldn't an intersection ratio of 0 mean it's not visible? But if we have code that is only executed if this ratio is non-zero, it will never be run. Furthermore, if only a single node is being observed and by skipping the intersection ratio of 0, no other events will fire, and no content updates will be performed.
The solution to this is using the isIntersecting
property which is only true if this node is, or is becoming, visible. Unfortunately, if this code was being written in TypeScript, this property, at the time of this writing, did not exist in the IntersectionObserverEntry interface, so it would be easy to miss.
Caution: Giant Child
When creating a new Intersection Observer, a number of configuration options may be passed, including a number of thresholds that allow for an intersection entry and an associated event to be fired as the percentage of its visibility changes.
In the W3C specification, an intersection entry is created when "intersectionRatio
is greater than the last entry in observer.thresholds
" where this ratio is "intersectionArea
divided by targetArea
." When a node is larger than the root node observing it, this ratio will steadily increase until the child node fills it, at which point the value will never reach 1 but remain the overall ratio of their two heights.
This can be confusing if we are expecting intersectionRatio
to steadily increase between 0 and 1, which isn't a goal of the Intersection Observer API, and has no logical way of being calculated. But even if this behavior is well understood, it should be noted that events stop firing at all once that ratio no longer changes. Even though intersectionRect.top
continues to change, and could be useful to our callback, the ratio itself is not changing.
In this demo, console logs show intersection entries for 3 nodes - above, giant, and below - with a large number of thresholds indicating every 1% change in intersection ratio. Pay attention to when "giant" fills the parent view and stops emitting events.
Caution: Duplicate or Missing Events
As the specification becomes clearer and edge cases are documented, there are going to be differences between browsers and the polyfill that should be expected and managed. Reading the discussion in this issue illustrates some of the areas in the specification that still need work, some areas where the specification was changed because of this discussion, and even explanations by browser developers as to why decisions were made the way they were.
In this example, we can open up the console to monitor the events. At the time of this writing, we could see Firefox occasionally emitting two entries as a node became visible. Although it's more of an edge-case, in the issue linked above, there are also situations where an event may not be emitted. Until these are corrected, ensure your implementation will not break, especially with duplicate events.
Problem: Polyfill
At the time of this writing, the Intersection Observer polyfill incorrectly overwrites native implementations of IntersectionObserver
due to a non-global reference. Previous versions failed to apply the polyfill where the native implementation was incorrect, meaning a patched version should be used until there is a new release.
The polyfill currently fires only on document scroll, window resize, and DOM mutation with a throttled/debounced intersection calculation after 100ms. An issue has been opened to add animation and transition events to cover more event types. The W3C specification notes that native intersection detection "[requires] extraordinary developer effort despite their widespread use" and so it should be expected that 100% coverage is going to be difficult to achieve.
Finally, there is a situation in which the polyfill will not report an intersection. Because it is entirely event-driven, calling .observe
on a node already in the DOM does not calculate intersections. We have submitted an issue that recreates this situation.
Caution: scrollTop
While this word of warning doesn't directly relate to intersection observers, it is likely to cause grief when using a scrolling inline element. Browsers have chosen different approaches to what happens when nodes are mutated within a scrolling inline element.
In Chrome, adding and removing nodes will automatically adjust the scroll position of the parent, through the scrollTop
property. Other browsers - Safari, for example - do not perform this calculation. Because of this, you will need to work around this limitation by manually adjusting scrollTop
based on size changes to nodes that appear before the first visible row.
Prognosis: Getting There
If it can be assumed that all users visiting a rich web application will be on the latest version of the leading browsers, there is enough active development and bug-squashing to assume we'll have a stable API in the near future.
But because most projects cannot make this assumption, the polyfill will have to stand in when needed. While we also expect this code to improve, there are inherent limitations to what can be calculated without having access to the rendering pipeline and native event loop. Using straightforward CSS and knowing the supported events match your use case should result in usable intersection events.
Learning More
SitePen provides web application development & consulting to enterprise teams worldwide. Connect with SitePen today to expand your team's experience, expertise and ability to achieve more.
About Neil Roberts and SitePen
Neil Roberts is a software engineer at SitePen, a web development and consulting company specializing in the modernization of enterprise applications & teams. When not meeting the demands of his 1-year old son, writing the next generation of dgrid, hanging out at Disney World or providing on-demand development for SitePen customers, Neil creates board games and has been known to spelunk. Like Goldilocks, Neil doesn’t like things too hot or too cold, but just right.
I am trying to learn IntersectionObserver after your this post. According to https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API the ‘threshold’ specifies the visibility ratio with respect to the root element. In my this example https://jsfiddle.net/fxa2o5pw/1/ you can see, the alert is fired after ‘load()’. I am not sure, why the callback is called on load?
The element is not visible, the threaded value is more than 1.0 which means the callback should only be called when an entire element is in the viewport. Please correct me if I am understanding this bit wrong. Thanks :)
Great overview! I’m not sure whether having good polyfill is so significant. Providing consistent behaviour is crucial for wide adoption but other than than (legacy browsers) it can be just introduced in the spirit of progressive enhancement. Majority of use cases Intersection Observer will be covering probably focus around lazy loading (images, API calls etc.). Nice, not crucial to access the content though.
@Michal, we’re fairly far down the path of using Intersection Observers for a new version of dgrid (a data grid component), so it’s pretty key for us to have a good polyfill, but it’s definitely less important for the other lazy loading use cases you’ve described.
I have been using Intersection Observers within a small table/grid, which happens to be an Anguarjs directive. Everything works great the first time that I route to the view that has this table containing the Intersection Observer. However, if I route to another page and back it no longer seems to get events.
I’m really confused as to how this could be possible. The Intersection Observer gets created within the Angularjs directive everything time the route is selected.
Anyone have any ideas?