Determine When A Sticky Element is Stuck in JavaScript
Using position: sticky is a handy CSS positioning property that allows elements to stick to the top of a relative parent on scroll. It's a considerable step forward compared to the older methods of scrolling down the page, past a specific offset and dynamically "fixing" the element to the browser window using position: fixed.
The Challenge
It's not possible in CSS to quickly determine when an element is "stuck." This can be frustrating, as sticky headers often require different designs/treatments when they scroll. It would be great if the spec provided a "pinned" or "stuck" pseudo-class, but that's not an option.
The Solution
To tackle this, you can use a nifty JavaScript function to determine when an element is "stuck":
Let me explain what this function does and why I have yet to use an IntersectionObserver, as that would be far more performant.
Explanation of The Code
Even though this function is relatively simple, let's explain the easy-to-understand parts first. We create a function called determineStickyState, which takes the sticky element as a parameter.
determineStickyState is bound to a scroll event listener on the window object. We leverage a throttle function here to minimize the scroll event's performance aspect, which is costly for the browser. We don't want determineStickyState to fire every time the delta of the scroll event fires (which is often)—throttle helps us improve performance by only firing the event once every 200 milliseconds. Doing so does not affect the user experience but goes a long way for performance.
Let's explain the function's body now. The last line is simple enough: we toggle a CSS class called "is-sticky" if currentTop is less than or equal to stickyTop.
As a side note, most engineers are unaware of the force parameter that is accepted by classList.toggle(), as per MDNs docs:
"If included, it turns the toggle into a one-way-only operation. If set to false, the token will only be removed but not added. If set to true, the token will only be added but not removed."
Using "force" provides a unique stateful parameter that can toggle the class when needed.
Now for the rest of the function. We declare two unscoped variables: stickyElementStyle and stickyElementTop. We then check if stickyElementStyle is not defined. We do this because window.getComputedStyle is an expensive computation, especially when firing on an event like scroll.
Doing so allows us to cache both values for performance. We can return to the cached value if the element's style does not change. stickyElementTop is run through parseInt, ensuring we deal with a Number data type. We grab the top value that we can access with getComputedStyle.
Finally, we calculate the element's current position using getBoundingClientRect().top
What is the difference? Well, getBoundingClientRect returns information about the element's size; one of the properties bound to this function is top, which describes the overall position and size of the component in question.
getComputedStyle grabs hardcoded data from CSS, the top value we define. These values determine if the currentTop (getBoundingClientRect) is less than or equal to stickyElementTop (getComputedStyle), which toggles our class.
Why Does Intersection Observer Not Work
The IntersectionObserver API would have been my first choice in this scenario. It's far more powerful and performant than binding an event listener to the window. The problem, though, is that to force the observer to understand when a sticky element is stuck, you have to use a slight hack in CSS by setting the top value of the component to the top: -1px. You can read more about this workaround on CSS Tricks.
This approach would be acceptable if the sticky element were a header that would always be the first element on the page. But in my case, the sticky element was offset down the page; I needed the top value to be a positive number that accounted for the existence of an already sticky element (i.e. the site header)
The Role of Throttling
The functionality above is bound to the window's scroll event. This event is quite taxing on browser performance as it fires often as the user scrolls up and down. We don't want the determineStickyState function to fire on every single delta, so we can use a throttle function that limits execution to once every 200 milliseconds; this improves performance while maintaining responsiveness.
The Purpose of This Article
Using position: sticky can significantly improve the user interface but often requires an intricate strategy to deal with its dynamic behaviour. I aim to empower fellow developers to create more engaging and effective web designs by providing this function. The code is available at the following Gist.