# Crafting a Twitter like sticky sidebar

## 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1695577946532/5febb774-9b3c-4f6e-a68d-076dc311c5fb.gif align="center")

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:-

```xml
<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`](https://gist.github.com/paulirish/5d52fb081b3570c81e3a) whereas reading values in`IO` 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:-

```xml
<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.

%[https://github.com/lakbychance/playground/tree/main/packages/ui/Sticky] 

Let's observe some set of important conditions(inside a scroll handler) that give the sidebar that **sticky** behaviour

```typescript
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:-

```typescript
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:-

```typescript
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](https://github.com/lakbychance/playground/tree/main/packages/ui/Sticky) repo where you can see the full implementation and also access an [example](https://playground-7fhyqni90-lapstjup.vercel.app/) 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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1695581890017/c61f26c7-88ce-440f-bc4e-9760205ab8a0.gif align="center")

## 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`](https://github.com/codecks-io/react-sticky-box) and [`react-sticky-box`](https://github.com/yahoo/react-stickynode) 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.
    

![](https://media.tenor.com/adh_cIhYhsoAAAAC/lets-get-into-a-discussion-about-this-discussion.gif align="center")
