Detecting when an element becomes sticky

Published June 3, 2025

8 min read

JavaScript

I recently stumbled upon a lovely detail on Luma’s websitewhen scrolling down a list of events. As you scroll past the date headings, they turn into floating badges.

There’s no way to use CSS to style an element when it becomes sticky, so how the hell did they do it?

Making an element sticky is easy#

Having elements become automatically sticky when scrolling is very simple. You just need to set position: stickyon the element and then set an inset property to control the offset relative to the nearest scrolling ancestor.

.sticky {
position: sticky;
top: 0;
}

Now any element with this class will be static by default, but will become sticky when it hits the top of the nearest scrolling ancestor, which is usually the viewport. It will be offset by 0.

If we take our Luma example from earlier, we can use sticky positioning on the date headings like so:

13 May Tuesday
14 May Wednesday
15 May Thursday
We can use CSS to make an element sticky, but what about styling it differently when it becomes sticky? 🤔

Now, our headings become sticky when they hit the top of the viewport, or in this case the top of the scrolling ancestor.

But the headings just float over the content, which doesn’t look clean or as slick. Once you have images and text instead of the placeholders in my example, it will look even worse.

So, how can we style the headings to stand out when they become sticky?

Detecting when an element becomes sticky is tricky#

As of this writing, there is no way to detect when an element becomes sticky using CSS alone. We have to rely on JavaScript to detect when an element becomes sticky.

Thankfully, we have a Web API that can help us out: IntersectionObserver. If you’ve not heard of this API before, you’re about to discover one of modern web development’s best tools.

IntersectionObserver to the rescue#

At its simplest, the IntersectionObserver API allows you to detect the relative visibility of two elements, giving us a way to asynchronously observe (i.e. performant and non-blocking) when one of those elements intersects with the other.

Not intersectingIntersectingroot elementtargetWhere we're observingintersectionWhat we're tracking forintersection observation
IntersectionObserver allows us to track the visibility of an element within a root element, such as a scrollable ancestor or the viewport.

Typically you would use this API to detect when an element becomes visible within the viewport, or within a scrollable ancestor, but the API is very flexible, supporting many different use cases.

Sticky detection with rootMargin#

Sticky elements present us with a unique challenge when using IntersectionObserver: if the element is sticky, it unlikely intersects with the viewport until it hits the bounds of it’s container element, at which point it becomes fixed in place and is no longer sticky 🤔.

That’s where the rootMargin optioncomes in, which allows us to specify a margin around the root element (usually the viewport, but can be any element or scrollable ancestor).

root element"Sticky area"We know our targetis sticky when it hitsthis areaWhere we're observingintersectionManipulates wherewithin the root weconsider intersectionrootMargin-10px0px0px0px
The rootMargin property allows us to specify a margin around the root element, which is usually the viewport, but can be any element or scrollable ancestor.

rootMargin is given a string value of inset properties, and you can give it negative insets to create an inner margin.

By telling the observer to observe the element with a rootMargin of -10px 0px 0px 0px, for example, we’re telling the observer to consider the target element as intersecting when it’s 10px from the top of the viewport.

🧠 Here’s the big brain bit: by setting the rootMargin with a negative inset that matches the offset of the sticky element, we can reasonably deduce that when the element is within the rootMargin it is not sticky. So we just need to check for the inverse, and style accordingly.

The final solution#

We’ll use IntersectionObserver to observe when our target sticky element leaves our root element, but we’ll add a negative rootMargin to account for the sticky element’s offset.

13 May Tuesday
14 May Wednesday
15 May Thursday
The final solution, using IntersectionObserver to detect when an element becomes sticky.

As an added measure here, we’re also checking if the element is intersecting where we expect it to be to avoid false positives (i.e. we think the element is sticking, but we just haven’t scrolled past it yet).

Finally, we’ll use the dataset property to store the state of the element, as that then allows us not only to style the element with simple CSS, but gives us the potential to run code when that state changes if desired.

Combining all of that above, we end up with some plain JavaScript like so:

const stickyElements = document.querySelectorAll(".detect-sticky")
if (!stickyElements) return
stickyElements.forEach((stickyElement) => {
const observer = new IntersectionObserver(
([e]) => {
let isSticky = false
if (e.intersectionRect.top === e.rootBounds?.top) {
isSticky = true
}
stickyElement.dataset.currentlySticky = String(isSticky)
},
{
rootMargin: "-15px 0px 0px 0px",
threshold: [1],
},
)
observer.observe(stickyElement)
})

Let’s step through the details inside the IntersectionObserver callback.

let isSticky = false
if (e.intersectionRect.top === e.rootBounds?.top) {
isSticky = true
}

Here we’re checking if the top edge of the intersection rectangle exactly matches the top edge of the root bounds. If it does, we know the element is sticking to the top of the root element, and we can set the isSticky variable to true.

You’ll want to change the check here if you’re using something other than the top inset property for the rootMargin.

stickyElement.dataset.currentlySticky = String(isSticky)

We set the dataset.currentlySticky property to the isSticky variable, which we can then use in our CSS to style the element accordingly.

In addition to the callback, we’re also passing some options to the IntersectionObserver.

{
rootMargin: "-15px 0px 0px 0px",
threshold: [1],
}

Adding a negative top margin to the observer’s root, we account for the sticky element’s offset (including margin and padding). It’s a good idea to add a couple of pixels on top of the sticky offset, as sticky elements don’t become sticky until moving past the sticky offset, and there can be rounding errors. You should play around to get a feel for the right value.

The threshold optionisn’t something we’ve covered in detail here, but it’s an array of numbers between 0 and 1 that represent the percentage of the element that must be visible in order to be considered an intersection.

In this case, we’re saying that the element must be 100% visible in order to be considered an intersection. This is important because we want to avoid false positives, where we think the element is sticking, but we just haven’t scrolled past it yet.

And that’s it! We’ve now got a way to detect when an element becomes sticky, and we can use that state to style the element accordingly.