Photo by Kelly Sikkema on Unsplash
There isn’t a way to monitor stickiness of a component in CSS (position: sticky
).
This nice article on Google, An event for CSS position:sticky shows how to emulate sticky events in vanilla JavaScript without using scroll event but using IntersectionObserver.
I will show how to create React components to emulate the same behavior.
Table of Contents
- Prerequisite
- What we are building
- Using sticky event components
- Implementing Sticky Components
- Resources
Prerequisite
This article is based on An event for CSS position:sticky, which also provides a nice demo and explanation on how it was implemented as well as the source code.
The basic idea is that, you add top & bottom sentinels around the sticky boundary, and observe those sentinels using IntersectionObserver
.
Left is the terms used in the linked article above and the right is corresponding component name used here.
- Scrolling Container ->
<StickyViewport />
- Headers ->
<Sticky />
- Sticky Sections ->
<StickyBoundary />
What we are building
Before moving on, let’s see what we are building.
Sticky headers styles are changed as they stick and unstick without listening to scroll event, which can cause site performance issue if not handled correctly.
Here is the working Sandbox.
You can click on Toggle Debug
button to show sentinels.
You can see that the sticky headers change the color and the box shadow styles.
Let’s see the usage of sticky components.
Using sticky event components
Here is the how one might use the component to observe un/stuck events.
- Specifies the viewport in which the IntersectionObserver should base on “threshold” with (root). By default, IntersectionObserver’s root is set to the viewport.
as
specifies which element the DOM should be rendered as. It’s rendered asmain
in this case where default isdiv
. - shows the section within which the sticky component sticks. (This is where “top/bottom” sentinels are added as shown in the Google doc)
- The boundary is where the un/stuck events can be subscribed via following props.
- Render a sticky component as “h1” – This is the component that will stick within the
StickyBoundary
on scroll. - shows event handlers.
handleChange
handler changes the background color and the box shadow depending on sticky component’s stickiness.
Now let’s see how each component is implemented.
Implementing Sticky Components
I will start from top components toward the bottom because I’ve actually written the rendered component (how the components should be used) before writing down implementations for them.
I wasn’t even sure if it’d work but that’s how I wanted the components to work.
⚛ StickyViewport
Let’s take a look at how it’s implemented.
- It’s basically a container to provide a context to be used within the Sticky component tree (“the tree” hereafter).
- The real implementation is within
StickyRoot
, which is not used (or made available via module export) in the usage above.
- While
StickyViewport
makes context available within the tree without rendering any element,StickyRoot
is the actual “root” (of IntersectionObserver option).
- To make the container ref available down in the tree, action dispatcher is retrieved from the custom hook,
useStickyActions
(,which is adispatch
fromuseReducer
) in the provider implementation. - Using the
dispatcher.setContainerRef
, we make the reference available in the tree for the child components.
Now let’s see what state and actions StickyProvider
provides in the tree.
⚛ StickyProvider
The context is implemented using the pattern by Kent C. Dodd’s article, How to use React Context effectively.
Basically, you create two contexts, one for the state, another for dispatch and create hooks for each.
The difference in StickyProvider
is that, instead of exposing raw dispatch
from useReducer
directly, I’ve encapsulated it into actions.
I’d recommend reading Kent’s article before moving on.
-
containerRef
refers to the ref inStickyRoot
, which is passed to the IntersectionObserver as theroot
option whilestickyRefs
refers to all<Sticky />
elements, which is the “target” passed to event handlers. -
setContainerRef
is called in theStickyRoot
to pass toStickyBoundary
whileaddStickyRef
associates TOP & BOTTOM sentinels with<Sticky />
element. We are observing TOP & BOTTOM sentinels so when<StickyBoundary />
fires events, we can correctly retrieve the target sticky element. - I am not returning a new reference but updating the existing “state” using
Object.assign(state,...)
, notObject.assign({}, state, ...)
. Returning a new state would infinitely run the effects, so onlystickRefs
are updated as updating the state reference would causecontainerRef
to be of a new reference, causing a cascading effect (an infinite loop). -
StickyProvider
simply provides states raw, and - creates “actions” out of dispatch, which makes only allowable actions to be called.
- and
- are hooks for accessing state and actions (I decided not to provide a “Consumer”, which would cause a false hierarchy as render prop would.).
-
StickySectionContext
is just another context to pass down TOP & BOTTOM sentinels down toSticky
component, with which we can associate the stickytarget
to pass to the event handlers foronChange, onUn/Stuck
events.
It was necessary because we are observing TOP & BOTTOM sentinels and during the declaration, we don’t know which sticky element we are monitoring.
Now we have enough context with state & actions, let’s move on and see implementations of child components, StickyBoundary
, and Sticky
.
⚛ StickyBoundary
The outline of StickyBoundary
looks as below.
- The boundary is where you’d subscribe stickiness changes.
- Create TOP & BOTTOM sentinel references, with which, we observe the stickiness of sticky components.
- Compute sentinel offsets.
- This hook observes top sentinel and fires events depending on the boundary calculation in relation to the viewport.
- This hook observes BOTTOM sentinel and fires events depending on the boundary calculation in relation to the viewport.
- Saving the sentinel refs to associate with sticky component somewhere down in the tree.
-
StickyBoundary
simplys wraps the children with TOP & BOTTOM sentinels and applies computed offsets calculated in step 3.
So basically StickyBoundary
wraps children with TOP & BOTTOM sentinels, with which we can tell whether a sticky component is stuck or unstuck.
Now let’s implement hooks.
🎣 useSentinelOffsets
- TOP margin & BOTTOM height calculation requires the top sentinel ref.
- This is where the calculation occurs whenever sticky elements, and top sentinel ref changes (
[stickyRefs, topSentinelRef]
). - We’ve associated sticky elements with TOP & BOTTOM sentinels via context, so fetch the sticky node associated with the top sentinel.
- Get the sticky element styles required for calculation.
- Calculate the BOTTOM sentinel height.
- We make the calculated states available to the caller.
🎣 useObserveTopSentinels
OK, this is now where it gets messy a bit. I’ve followed the logic in the Google doc so will be brief and explain only relevant React codes.
- These are the events to be triggered depending on the TOP sentinel position.
- We have saved the references via context actions. Retrieve the container root (viewport) and the stick refs associated with each TOP sentinel.
- This is where observation side effect starts.
- The logic was “taken” from the Google doc, thus will skip on how it works but focus on events.
- As the TOP sentinel is moved up, we fire the “stuck” event here.
- And when the TOP sentinel is visible, it means the sticky element is “unstuck”.
- We fire whenever either unstuck or stuck is even fired.
- Observe all TOP sentinels that are registered.
🎣 useObserveBottomSentinels
The structure is about the same as useObserveTopSentinels
so will be skipping over the details.
The only difference is the logic to calculate when to fire the un/stuck event depending on the position of BOTTOM sentinel, which was discussed in the Google doc.
Now time for the last component, Sticky
, which will “stick” the child component and how it works in conjunction with aforementioned components.
⚛ Sticky
- First we get the TOP & BOTTOM sentinels to associate with
- so that we can retrieve correct child target element from either a top sentinel or a bottom sentinel.
- We simply wrap the children and apply
position: sticky
around it using a class module (not shown here).
Let’s take a look at the working demo one more time.
Resources
- Google Documentation
- MDN
- IntersectionObserver
- IntersectionObserver root option
- Sandbox
The post React Sticky Event with Intersection Observer appeared first on Sung's Technical Blog.
Top comments (7)
Great article. I'm adapting your code for use in a project and I had two questions about
stickyRefs
.First, are these refs removed from the
Map
when items (akaStickyBoundary
+Sticky
pairs) are unmounted in a dynamic list? If not, then I assume this is not good because if new items are created dynamically then you'll end up with zombie refs sticking around in that Map... and therefore a memory leak.Second, why is this context stored at the
StickyViewport
level? From a cursory look at the code, it seems that this context could be stored at theStickyBoundary
level instead, which would simplify things because no Map would be needed, and would remove the memory leak risk noted above because if a section is unmounted then the context provider would be unmounted too. Is this correct?And one more question: I'm assuming that only one
<Sticky>
can be inside a<StickyBoundary>
. Is this correct? If yes then while moving the context as noted above, I may add some error handling to prevent duplicate<Sticky>
elements in a boundary.Thank you for the questions and thoughtful comments/suggestions, Justin.
No, they are not. Sticky refs are added using
addStickyRef
but not removed on unmount. That's a very nice catch. So<Sticky />
component should probably call a method like (not implemented in the post)removeStickyRef
to destroy the ref.You can have multiple
StickyViewport
components, each of which can fire different intersection events. TheStickyViewport
hascontainerRef
, against which intersection observer (for eachSticky
component) events are fire, not relative toStickyBoundary
.You can have multiple
Sticky
components but event will fire for the last oneYes. You can have one
Sticky
component underStickyBoundary
. I believe the original implementation in Google doc didn't allow this either (maybe too restrictive)...Too much code... the over-complexity of this is outstanding
I can't disagree at all, @vsync
It must be due to my lack of understanding of hooks & IntersectionObservers.
Would you have any direction I can take to make it simpler?
Hi,
Can you suggest a free "React multi-select Searchable hierarchy tree dropdown" component in React Web Dev.
For more details : thread link
Sticky is always tricky :) thanks for the post, and the list of resources!
You're welcome & great rhyming, Chris 🤣