Introduction
You're scrolling through Twitter (I still like calling it that) or Medium and you get a nice view of the sidebar as it snaps to the bottom or top of the page if you try to scroll beyond its boundaries.
We will re-create the same experience through a react
implementation.
But before that, let's observe how the properties of these sidebar elements mutate as you scroll the viewport in existing implementations.
Observations
Twitter - On Twitter, as you scroll from top to bottom of the screen and the bottom of the sidebar comes into view, the sidebar's
position
becomessticky
with a mutatedtop
value. There is also amargin-top
on the sidebar element. Now if you scroll from bottom to top the sidebar remainssticky
but now with a mutatedbottom
value. Again, themargin-top
value changes in this case.Medium - On Medium, as you scroll from top to bottom of the screen and the bottom of the sidebar comes into view, the sidebar's
position
becomessticky
with a mutatedtop
value. Now if you scroll from bottom to top, the sidebar'sposition
isrelative
along with a mutatedtop
value.
I found that Medium
was able to get the bottom stickiness working without explicitly setting a bottom
value and no margin-top
manipulations (Although margin-top
does exist on the element, removing it didn't do anything). So only two properties, namely top
and the position
needed to be mutated if we went with this implementation.
Considerations
With that in mind, I first thought of an Intersection Observer
implementation where I will use two pseudo
divs wrapping the actual content and observing their intersection with the viewport to make certain decisions on how to mutate the sidebar properties.
Here is how the HTML structure for this implementation looked like:-
<div> - Sidebar element
<div/> - Top pseudo div
{children} - sidebar content
<div/> Bottom pseudo div
</div>
While making this work, I realized that it might be hard to not use stuff like getBoundingClientRect
or offsetHeight
properties to compute certain cases which Intersection Observer (IO)
might not account for. I thought to avoid those for performance reasons as reading them may result in reflows
whereas reading values inIO
doesn't cause that.
So I shifted to pure DOM calculations from the IO
approach to keep it standardized. It might not be the most ideal approach but works well.
Here is how the HTML structure for this implementation looked like:-
<div> - Sidebar element
{children}
</div>
Again, the calculations need to happen inside a scroll
listener because the logic depends on whether you're scrolling up or scrolling down.
Implementation
The full-fledged implementation is available below but I will be sharing and explaining those bits which cover the conditions that govern when the sidebar is sticky and when it's not.
Let's observe some set of important conditions(inside a scroll handler) that give the sidebar that sticky behaviour
if (isScrollingUp) {
if (isTopEndBetweenViewport) {
setPosition('sticky');
setTop(topPositionAtWhichElementBecomesStickyFromTop);
} else if (isBottomEndBetweenViewport) {
setPosition('relative');
setTop(topDistanceOfElementRelativeToPageTop - offset);
}
} else if (isScrollingDown) {
if (isTopEndBetweenViewport) {
setPosition('relative');
setTop(topDistanceOfElementRelativeToPageTop - offset);
} else if (isBottomEndBetweenViewport) {
setPosition('sticky');
setTop(topDistanceAtWhichElementBecomesStickyFromBottom);
}
}
Explanation:-
When a user is scrolling up:-
If the top of the sidebar is visible, then we set it's
position
assticky
. We also set it'stop
value to what we want it should start sticking from.If the bottom of the sidebar is visible, then we set it's
position
torelative
. We also set it'stop
value to what we know the element itself has offset by from the top of the page. So suppose you're at1234px
from the top of the page thentop
will become1234px
.
When a user is scrolling down:-
If the top of the sidebar is visible, then we set it's
position
asrelative
. We also set it'stop
value to what we know the element itself has offset from the top of the page. So suppose you're at436px
from the top of the page thentop
will become436px
.If the bottom of the sidebar is visible, then we set it's
position
tosticky
. We also set it'stop
value to what we want it should start sticking from the bottom. This will be mostly a negative value.
Now let's see more conditions within the scroll handler that do not depend on the scrolling direction but instead are required to exit early even before reaching the direction-based logic we discussed above:-
if (isTopEndBelowViewport || isBottomEndAboveViewport) {
setPosition('relative');
setTop(scrollYOffset);
prevScrollTop = scrollYOffset;
return;
}
if (areBothTopAndBottomEndsOnOppositeEndsOfViewport) {
setPosition('relative');
setTop(topDistanceOfElementRelativeToPageTop - offset);
prevScrollTop = scrollYOffset;
return;
}
if (areBothTopAndBottomEndsBetweenViewport) {
setPosition('sticky');
setTop(topPositionAtWhichElementBecomesStickyFromTop);
prevScrollTop = scrollYOffset;
return;
}
Explanation:-
If the top end of the sidebar is below the viewport or the bottom end of the sidebar is above the viewport, we will set it's
position
torelative
and itstop
toscrollYOffset
(alias forwindow.scrollY
). But when can we run into such a situation? It's possible while navigating between pages that the sidebar unmounts and mounts or some race conditions caused it to not be at its correct position. More of an extra check that can help mitigate those.If the top end of the sidebar is above the viewport and the bottom end of the sidebar is below the viewport, we set its
top
value to what we know the element itself has offset by from the top of the page.If the top end of the sidebar and bottom end of the sidebar are both visible in the viewport, we set its
position
assticky
. We also set it'stop
value to what we want it should start sticking from. If the sidebar is too long, this becomes more apparent in the zoomed-out view.
Let's explore the variables used in the above code snippets:-
topPositionAtWhichElementBecomesStickyFromTop
- This is user-providedtop
value. By default this is0
. You might want to change it to something else as per your needs. The sidebar calculations will factor in this property.topDistanceOfElementRelativeToPageTop
- This is calculated using theelement.offsetTop
property.offsetTop
is calculated relative to its nearestpositioned
parent, also called theoffsetParent
. If no suchpositioned
parent is present,body
is used for calculation.offset
- This is also theelement.offsetTop
property but the one that was calculated initially outside the scroll handler. This is used to account for stuff likepadding
,margin
,position
etc. and is needed to factor in the upcoming scroll handler calculations.topDistanceAtWhichElementBecomesStickyFromBottom
- This is theelement.getBoundingClientRect().top
value.getBoundingClientRect()
works by giving values of the element's position relative to the viewport. So when the bottom of the sidebar is visible, thegetBoundingClientRect().top
will generally result innegative
values for the top as it would be above the current viewport in most cases. So we can set that as thetop
value along withposition
assticky
to snap the sidebar to the bottom of the viewport.
All of the above code logic is abstracted in a useStickyElement
hook. This hook is then consumed by a Sticky
component which looks like below:-
export const Sticky = React.forwardRef<HTMLDivElement, ComponentProps>((props, ref) => {
const { children, top, as = 'div', ...restOfTheProps } = props;
const Element = as;
const stickyContainerRef = useRef<HTMLDivElement>(null);
const mergedRefs = composeRefs(ref, stickyContainerRef);
const { top: _top, position } = useStickyElement({
elRef: stickyContainerRef,
top,
});
return (
<Element
ref={mergedRefs}
style={{
top: _top,
position,
height: 'fit-content',
}}
{...restOfTheProps}
>
{children}
</Element>
);
});
In the above code, the height: 'fit-content'
CSS property is important. This is what allows the element to not occupy more space than the content it has and the hook calculations to work. If it's not present, the sidebar's height
will fall back to auto
which will make it occupy the rest of the unused space.
Limitations
The current implementation works great for cases where the elements above the sidebar won't resize or disappear. But if they do, the implementation cannot account for those because it relies on the initial element.offsetTop
. There is a way to make that work by simply umounting
and mounting
the Sticky
component to account for DOM changes and reinitialize the calculations. We can use different keys
on the Sticky
component in such cases that automatically ensures it.
Playground
Check out the GitHub repo where you can see the full implementation and also access an example to play with the sidebar by dynamically setting the elements on the main content or the sidebar itself. I have intentionally skipped sharing the verbose bits of the useStickyElement
hook in this article to focus on more conditional logic which makes it work.
Conclusion
The implementation doesn't try to be generic but is specific to the use case of getting that Sticky scrollable element behavior as we see in the sidebars.
For the readers of this article, I have some open-ended questions for you:-
How would you have implemented it differently? Do you have a less JS and more CSS-based implementation in mind?
What optimizations can be done to the current implementation?
What libraries are doing this great? For eg; I played with
react-stickynode
andreact-sticky-box
before writing this implementation. They both worked fine but I noticed with dynamic loading content (infinite scrolling) or zooming in and out, there was some jumpy behavior. Although they are made with much more configurability in mind.