22min

October 6, 2025

Implement a scroll-aware sticky behavior

How to make smarter sticky components

How to make smarter sticky components

A sticky element seems very intuitive to have except when its content is too long and you can't scroll through them, this post covers how I deal with those situations.

ReactImplementation
How to make smarter sticky components

Overview

Sticky elements are essential if you ever need a piece of information to stick on the screen when scrolling, which includes use cases like:
  • A header element that stays on the viewport when users scroll downward on the page.
  • A heading of a page's section that stays on screen to inform users which part of the content they're still seeing.
  • A pinned table column or header that also doesn't move when users scroll vertically or horizontally to see different table cells. This behaviour is convenient for table navigation and cross-column comparison.
In fact, sticky elements are designed to enhance a particular user experience, specifically by avoiding repetitive and annoying scrolling on supposedly handy elements on the page. The claim is also valid for fixed elements, but that's an off-topic thing since we'd probably never expect them to reflect our scrolling.
The problem here is that sometimes the sticky elements are long by themself, such as this table of contents side view.
In situations like this, a quick solution would be to make the element scrollable as well. However, as you can see, this defeats the entire purpose of its convenience and anti-scroll nature, not to mention that we now have at least two scrollable elements on the screen, which is a prime example of poor UX design.
A better UX experience would require the sticky element not to be scrollable, but to continue acting as if it is not sticky at all when not fully visible in that scroll direction. This way, the element does not get stuck at the top visible part the whole time. It should only become sticky again after all of its content has been revealed in the direction, and again becomes not sticky right when we change the scroll direction.

Technical Solution

Approach description

Take a sidebar as an example. If we set a top value for it and position it as sticky, it should "stick" to the top of the scroll view for as long as the main content needs to be. We define the sticky position state of the element by the inset properties (top, bottom, inset-block-start, etc). The general idea is to define these values in a way that allows the element to be overscrolled if its content is too long to fit in the scroll view before reaching its sticky phase, thereby avoiding the dead hang situation we encountered in the example above.
In other words, when we scroll down, the sidebar should remain scrollable until its bottom has reached the scroll view's top edge, which means it needs to be scrollable for a little longer, by the amount of the sidebar's height. Furthermore, regardless of how deep we have scrolled down, whenever we start to scroll up or switch scroll direction, the sidebar should react to the scroll instantly if its content is being clipped at the top, by flowing with the scroll direction, ignoring its stickiness state. The sidebar only becomes sticky again when its top has been entirely visible. We solve the problem by going through two different phases: the scrolling-up handling phase and the scrolling-down handling phase.

Scrolling-down phase

To solve the sticky behaviour in this case, the sidebar is allowed to be over-scrolled by an amount that is explicitly equal to the difference between the sidebar's height and the scroll view height.
Δ=max(0,DsidebarviewDscrollview)\Delta = max(0, D_{sidebarview} - D_{scrollview})
If the sidebar height is smaller than the scroll view's, we don't need to perform any extra fancy logic. Therefore, the height difference is ignored, Δ\Delta is 0. Otherwise, by setting top to Δ-\Delta, we explicitly allow the element to be sticky at the top only after it has scanned through an additional length of Δ\Delta. Creating the always-visible effect we want.
However, the moment we start to scroll upwards in the middle of the page, the top value prevents the element (whose bottom is currently visible due to being fully over-scrolled) from revealing more of its top again.
If you think we can simply add a bottom value to the element and switch between top and bottom on scroll direction change, that's a perfect idea. However, it won't work yet, because the original, natural position of the sidebar is still at the very top of the page, which means there has never been a scroll threshold that is above the sidebar to make bottom active, as opposed to how top was active due to the scroll view threshold being below the top of the sidebar as we scroll.

Scrolling up phase

To resolve the above problem, we probably still want bottom to somehow work; one way to make it work is to also shift the natural position of the sidebar by an amount, using an additional spacer above it, which helps push the sidebar down. As long as the scroll view threshold is intersecting with that spacer, it is already technically above the sidebar, which allows bottom to be active instantly when we decide to scroll upward. If that can be done, setting bottom as Δ-\Delta as well would create the perfect effect we want.
In the perfect scenario, we want the spacer to shift the sidebar's natural position to its current sticky position. This way, when the bottom sticky anchor is activated, it's perfectly aligned and unrecognisable. To achieve this, we listen to scroll events and update the spacer to grow/shrink based on the scroll distance, ensuring the sidebar's normal flow position matches the current sticky position on screen.
if (lastScrollDirection !== currentScrollDirection) {
      const sidebarContainerTop = sidebarContainer.getBoundingClientRect().y ?? 0; 
      const sidebarElementTop = sidebar.getBoundingClientRect().y ?? 0;
      let spaceNeeded = Math.max(0, sidebarElementTop - sidebarContainerTop);
      // ...
}
Here, spaceNeeded is the height of the spacer, estimated from its container, in order to "push the sidebar" down to its currently on-screen position. Every time the scroll direction changes, we need to update the spacer before detaching/attaching top or bottom respectively, in order to keep the sidebar stable on screen.
let lastScrollY = 0;
let lastDirection = 'down';

scrollContainer?.addEventListener('scroll', () => {
    if (!scrollContainer || !sidebar || !sidebarContainer) return;
    const newScrollY = Math.max(0, scrollContainer.scrollTop);
    const direction = newScrollY >= lastScrollY ? 'down' : 'up';

    const scrollViewHeight = scrollContainer.clientHeight ?? 0;
    const elementHeight = sidebar.clientHeight ?? 0;
    const heightDelta = elementHeight - scrollViewHeight;

// Update spacer height when scroll direction changes
    if (lastDirection !== direction) {
        const sidebarContainerTop = sidebarContainer.getBoundingClientRect().y ?? 0;
        const sidebarElementTop = sidebar.getBoundingClientRect().y ?? 0;
        let elementRelativeTop = Math.max(0, sidebarElementTop - sidebarContainerTop);
        elementRelativeTop = Math.round(elementRelativeTop);
        scrollContainer?.style.setProperty('--scroll-y', `${elementRelativeTop}`);
    }

// Set `top` or `bottom` based on scroll direction
    if (direction === 'down') {
        sidebar.style.setProperty('top', `${-heightDelta - defaultBottom}px`);
        sidebar.style.removeProperty('bottom');
    } else {
        sidebar.style.setProperty('bottom', `${-heightDelta - defaultTop}px`);
        sidebar.style.removeProperty('top');
    }
    lastDirection = direction;
    lastScrollY = newScrollY;
});
With this trick, we can ensure the sidebar element always responds to our scroll operation, regardless of the direction we switch, to fully cover the entire content of the sidebar without the need for an additional scrollbar. We can always add additional touches, such as allowing default top and bottom paddings, updating styling on animation frame for optimised performance, or observing the element's height change to update the calculations, but the idea remains the same.

Recent posts

Why I made my own CMS cover

September 25, 2025

Why I made my own CMS

My Blog Tour