Recently, a client had a design spec that required different sections of the page to have a sticky scrolling header. Essentially, the header for the section would ‘stick’ to the top of the viewport, until it hit the next section, where the second header would then stick to the top of the viewport. Easy enough, right?
I’d worked on projects where we achieved the same interaction — particularly for one client who spec’d animations and scroll interactions based off of Apple’s web designs (like this). While working with a parallax-like effect could typically be difficult, we were lucky enough to use ScrollMagic, an incredible JS library to achieve really snappy and eye-catching animations!
For this project, though, I needed to achieve these sticky section headers, without a library. My initial approach was to use a `scroll` event listener. I set up React refs at the top of each header, and in the event listener triggered a ‘.stuck’ class to be added to the header, once the scroller hit a ref. This worked out relatively easy — achieving what the designs specified.
However, to take it a step further, we wanted to optimize for performance. Having an event listener running constantly on the page, was not performative at all, so I looked into other options. That’s where Intersection Observer API came in handy. The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element (in this case, a header) with the viewport (or any other ancestor element). Besides a nice sticky header, other uses for the Intersection Observer API include:
- Triggering animations or jobs based on element visibility
- Implementing ‘infinite scroll’ effect
- Lazy-loading images or other media content while user scrolls
Pretty useful! Here’s how to set it up:
1) Target your element
You can use jQuery, or React refs, like below. This is the element you want to be ‘observed’:
const [isSticky, setIsSticky] = useState<boolean>(false)
// this is the element we'll be targeting!
const headerRef = useRef<HTMLDivElement>(null)
<body>
<section>
<div
className={isSticky ? 'stuckHeader' : 'unStuckHeader'}
ref={headerRef}
>
<h1>This is my header! </h1>
</div>
<div>
<p> content </p>
</div>
</section>
</body>
I’ve added the `headerRef` to the DOM and also added the `isSticky` state, to change the className for the header. This way, once triggered, we can alter the sticky header versus unsticky header styles appropriately.
2) Create the observer & observer callback
As long as this component is mounted, we want this element to be observed, so we can do this within a `useEffect` hook.
const [isSticky, setIsSticky] = useState<boolean>(false)
// this is the element we'll be targeting!
const headerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const header = headerRef?.current
const observer = new IntersectionObserver(
([e]) => {
// e is our target element -- the header;
// other properties available include:
// boundingClientRect
// intersectionRatio
// intersectionRect
// rootBounds
// target
// time
setIsSticky(e.isIntersecting)
},
{threshold:[0.1,1]}
)
if(header) {
observer.observe(header)
}
// clean up the observer
return (() => {
observer.unobserve(header)
})
}, [headerRef])
In this hook we are observing our target element - the header - and within the callback, setting state based on whether or not our target element is intersecting with the `root`. By setting state, we trigger the appropriate classname to be used for the element. Lastly, we clean up our observer, so we don’t have extra computations running needlessly.
A few things to note. I mentioned the intersection with the `root` — along with `threshold` the Intersection Observer can take other options:
threshold: An array of numbers representing what percentage of the target element’s visibility we should execute the callback. Here, we are saying when the element is 10% in view and when the element is 100% in view, execute the callback.
root: The element to check for visibility within. By default this will be the browser viewport. However, this can also be any ancestor element.
rootMargin: Any margin around the root. By default this is 0, but should be used if any root element specified also has margin.
3) Use state to update/modify the element
In this case, we read the boolean `isSticky` and set a className to designate different styles to be applied to the header.
And there you go! You should have a working Intersection Observer, with a sticky header that changes based on whether it’s ‘stuck’ to the top of the viewport or not.
One last thing…
One gotcha I came across was a scrolling glitch, anytime the header was a few pixels from the top of the viewport. In my case, I was using `position: sticky`, which inevitably caused the glitch as I was resizing the header’s height. Since we’re setting state, the Intersection Observer was setting `isSticky` intermittently when hitting that part of the page, since the header was re-sizing constantly (basically looping). A fix for this I found to be successful was to either ensure your element maintains the same sizing within the viewport, otherwise the observer will overwork.