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
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 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 With these use cases in mind, we've crafted an end goal: create an event that
fires when a The demo uses
this event to headers a drop shadow when they become fixed. It also updates the
new title at the top of the page. Let's get some terminology out of the way so I can refer to these names
throughout the rest of the post: 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 So without scroll events, we've lost the ability to perform layout-related
calculations on the headers. Instead of We need two sentinels to cover four cases of scrolling up and down: It's helpful to see a screencast of 1-4 in the order they happen: The sentinels are positioned at the top and bottom of each section.
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 First, I set up observers for the header and footer sentinels: Then, I added an observer to fire when The observer is configured with The process is similar for the bottom sentinel ( The observer is configured with Lastly, there's my two utilities for firing the That's it! We created a custom event when elements with I've often wondered if 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 In the future, a
"Style Mutation Observer"
extension to Mutation Observers might be useful to observe changes to an
element's computed styles.
Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 4.0 License, and code samples are licensed under the Apache 2.0 License. For details, see the Google Developers Site Policies. Java is a registered trademark of Oracle and/or its affiliates. Last updated 2017-09-19 UTC..sticky {
position: sticky;
top: 10px;
}
position:sticky
could unlock a number of use cases:
position:sticky
element becomes fixed. Let's call it the
sticky-change
event:document.addEventListener('sticky-change', e => {
const header = e.detail.target; // header became sticky or stopped sticking.
const sticking = e.detail.stuck; // true when header is sticky.
header.classList.toggle('shadow', sticking); // add drop shadow when sticking.
document.querySelector('.who-is-sticking').textContent = header.textContent;
});
Scroll effects without scroll events?
position:sticky
.position:sticky
is applying to the element.scroll
events :) The other problem is that
position:sticky
removes the element from layout when it becomes fixed.Adding dumby DOM to determine scroll position
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 CSS
.sticky_sentinel--top
sits on the top of the header while
.sticky_sentinel--bottom
rests at the bottom of the section:
:root {
--default-padding: 16px;
--header-height: 80px;
}
.sticky {
position: sticky;
top: 10px; /* adjust sentinel height/positioning based on this position. */
height: var(--header-height);
padding: 0 var(--default-padding);
}
.sticky_sentinel {
position: absolute;
left: 0;
right: 0; /* needs dimensions */
visibility: hidden;
}
.sticky_sentinel--top {
/* Adjust the height and top values based on your on your sticky top position.
e.g. make the height bigger and adjust the top so observeHeaders()'s
IntersectionObserver fires as soon as the bottom of the sentinel crosses the
top of the intersection container. */
height: 40px;
top: -24px;
}
.sticky_sentinel--bottom {
/* Height should match the top of the header when it's at the bottom of the
intersection container. */
height: calc(var(--header-height) + var(--default-padding));
bottom: 0;
}
Setting up the Intersection Observers
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./**
* Notifies when elements w/ the `sticky` class begin to stick or stop sticking.
* Note: the elements should be children of `container`.
* @param {!Element} container
*/
function observeStickyHeaderChanges(container) {
observeHeaders(container);
observeFooters(container);
}
observeStickyHeaderChanges(document.querySelector('#scroll-container'));
.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./**
* Sets up an intersection observer to notify when elements with the class
* `.sticky_sentinel--top` become visible/invisible at the top of the container.
* @param {!Element} container
*/
function observeHeaders(container) {
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
// Started sticking.
if (targetInfo.bottom < rootBoundsInfo.top) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.bottom >= rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [0], root: container});
// Add the top sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--top');
sentinels.forEach(el => observer.observe(el));
}
threshold: [0]
so its callback fires as soon
as the sentinel becomes visible..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./**
* Sets up an intersection observer to notify when elements with the class
* `.sticky_sentinel--bottom` become visible/invisible at the bottom of the
* container.
* @param {!Element} container
*/
function observeFooters(container) {
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
const ratio = record.intersectionRatio;
// Started sticking.
if (targetInfo.bottom > rootBoundsInfo.top && ratio === 1) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.top < rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [1], root: container});
// Add the bottom sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--bottom');
sentinels.forEach(el => observer.observe(el));
}
threshold: [1]
so its callback fires when the
entire node is within view.sticky-change
custom event
and generating the sentinels:/**
* @param {!Element} container
* @param {string} className
*/
function addSentinels(container, className) {
return Array.from(container.querySelectorAll('.sticky')).map(el => {
const sentinel = document.createElement('div');
sentinel.classList.add('sticky_sentinel', className);
return el.parentElement.appendChild(sentinel);
});
}
/**
* Dispatches the `sticky-event` custom event on the target element.
* @param {boolean} stuck True if `target` is sticky.
* @param {!Element} target Element to fire the event on.
*/
function fireEvent(stuck, target) {
const e = new CustomEvent('sticky-change', {detail: {stuck, target}});
document.dispatchEvent(e);
}
Final demo
position:sticky
become
fixed and added scroll effects without the use of scroll
events.Conclusion
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?
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.position: sticky
.