Register for this year’s #ChromeDevSummit happening on Nov. 11-12 in San Francisco to learn about the latest features and tools coming to the Web. Request an invite on the Chrome Dev Summit 2019 website

An event for CSS position:sticky

TL;DR

Here's a secret: You may not need scroll events in your next app. Using an
IntersectionObserver,
I show how you can fire a custom event when position:sticky elements become fixed or when they stop sticking. All without the
use of scroll listeners. There's even an awesome demo to prove it:

Introducing the sticky-change event

An event is the the missing feature of CSS position:sticky.

One of the practical limitations of using CSS sticky position is that it
doesn't provide a platform signal to know when the property is active.
In other words, there's no event to know when an element becomes sticky or when
it stops being sticky.

Take the following example, which fixes a <div class="sticky"> 10px from the
top of its parent container:

.sticky {
position: sticky;
top: 10px;
}

Wouldn't it be nice if the browser told when the elements hits that mark?
Apparently I'm not
the only one
that thinks so. A signal for position:sticky could unlock a number of use cases:

Apply a drop shadow to a banner as it sticks.

As a user reads through your content, record analytics hits to know their
progress.

As a user scrolls the page, update a floating TOC widget to the current
section.

With these use cases in mind, we've crafted an end goal: create an event that
fires when a position:sticky element becomes fixed. Let's call it the
sticky-change event:

Sticky sections - each content section. The text that scrolls under the
sticky headers.

"Sticky mode" - when position:sticky is applying to the element.

To know which header enters "sticky mode", we need some way of determining
the scroll offset of the scrolling container. That would give us a way
to calculate the header that's currently showing. However, that gets pretty
tricky to do without scroll events :) The other problem is that
position:sticky removes the element from layout when it becomes fixed.

So without scroll events, we've lost the ability to perform layout-related
calculations on the headers.

Adding dumby DOM to determine scroll position

Instead of scroll events, we're going to use an IntersectionObserver to
determine when headers enter and exit sticky mode. Adding two nodes
(aka sentinels) in each sticky section, one at the top and one
at the bottom, will act as waypoints for figuring out scroll position. As these
markers enter and leave the container, their visibility changes and
Intersection Observer fires a callback.

The hidden sentinel elements.

We need two sentinels to cover four cases of scrolling up and down:

Scrolling down - header becomes sticky when its top sentinel crosses
the top of the container.

Scrolling down - header leaves sticky mode as it reaches the bottom of
the section and its bottom sentinel crosses the top of the container.

Scrolling up - header leaves sticky mode when its top sentinel scrolls
back into view from the top.

Scrolling up - header becomes sticky as its bottom sentinel crosses back
into view from the top.

Setting up the Intersection Observers

Intersection Observers asynchronously observe changes in the intersection of
a target element and the document viewport or a parent container. In our case,
we're observe intersections with a parent container.

The magic sauce is IntersectionObserver. Each sentinel gets an
IntersectionObserver to observer its intersection visibility within the
scroll container. When a sentinel scrolls into the visible viewport, we know
a header become fixed or stopped being sticky. Likewise, when a sentinel exits
the viewport.

Then, I added an observer to fire when .sticky_sentinel--top elements pass
through the top of the scrolling container (in either direction).
The observeHeaders function creates the top sentinels and adds them to
each section. The observer calculates the intersection of the sentinel with
top of the container and decides if it's entering or leaving the viewport. That
information determines if the section header is sticking or not.

The observer is configured with threshold: [0] so its callback fires as soon
as the sentinel becomes visible.

The process is similar for the bottom sentinel (.sticky_sentinel--bottom).
A second observer is created to fire when the footers pass through the bottom
of the scrolling container. The observeFooters function creates the
sentinel nodes and attaches them to each section. The observer calculates the
intersection of the sentinel with bottom of the container and decides if it's
entering or leaving. That information determines if the section header is
sticking or not.

Final demo

Conclusion

I've often wondered if IntersectionObserver would
be a helpful tool to replace some of the scroll event-based UI patterns that
have developed over the years. Turns out the answer is yes and no. The semantics
of the IntersectionObserver API make it hard to use for everything. But as
I've shown here, you can use it for some interesting techniques.

Another way to detect style changes?

Not really. What we needed was a way to observe style changes on a DOM element.
Unfortunately, there's nothing in the web platform APIs that allow you to
watch style changes.

A MutationObserver would be a logical first choice but that doesn't work for
most cases. For example, in the demo, we'd receive a callback when the sticky
class is added to an element, but not when the element's computed style changes.
Recall that the sticky class was already declared on page load.

In the future, a
"Style Mutation Observer"
extension to Mutation Observers might be useful to observe changes to an
element's computed styles.
position: sticky.