Crafting a Twitter like sticky sidebar

Crafting a Twitter like sticky sidebar

ยท

8 min read

Play this article

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 becomes sticky with a mutated top value. There is also a margin-top on the sidebar element. Now if you scroll from bottom to top the sidebar remains sticky but now with a mutated bottom value. Again, the margin-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 becomes sticky with a mutated top value. Now if you scroll from bottom to top, the sidebar's position is relative along with a mutated top 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 as sticky. We also set it's top value to what we want it should start sticking from.

    • If the bottom of the sidebar is visible, then we set it's position to relative. We also set it's top value to what we know the element itself has offset by from the top of the page. So suppose you're at 1234px from the top of the page then top will become 1234px.

  • When a user is scrolling down:-

    • If the top of the sidebar is visible, then we set it's position as relative. We also set it's top value to what we know the element itself has offset from the top of the page. So suppose you're at 436px from the top of the page then top will become 436px.

    • If the bottom of the sidebar is visible, then we set it's position to sticky. We also set it's top 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 to relative and its top to scrollYOffset (alias for window.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 as sticky. We also set it's top 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-provided top value. By default this is 0. 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 the element.offsetTop property. offsetTop is calculated relative to its nearest positioned parent, also called the offsetParent. If no such positioned parent is present, body is used for calculation.

  • offset - This is also the element.offsetTop property but the one that was calculated initially outside the scroll handler. This is used to account for stuff like padding, margin, position etc. and is needed to factor in the upcoming scroll handler calculations.

  • topDistanceAtWhichElementBecomesStickyFromBottom - This is the element.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, the getBoundingClientRect().top will generally result in negative values for the top as it would be above the current viewport in most cases. So we can set that as the top value along with position as sticky 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 and react-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.

Did you find this article valuable?

Support Lakshya Thakur by becoming a sponsor. Any amount is appreciated!

ย