Dynamically 'sticky' with a custom hook

Reacts own doc has a page on custom hooks, but I feel my use case actually makes for a more practical example of the whole concept. Rather than using the results of a fictional API we can use results produced on an event on the window element.

Giving content some headroom...

When you scroll down on this page the header will move up and out of view. When you scroll back up, it reappears. When you scroll all the way up, the box-shadow disappears and it blends seamlessly back into its original spot.

This functionality is pretty standard on a lot of websites and numerous libraries exists that can do this for you. Once upon a time I used one called headroom.js and I still refer to this feature as "headroom".

Vanilla headroom

Because Headroom still uses jQuery I decided to rewrite it myself in plain JS and it turns out that this is pretty simple to do. I have turned to calling it 'scrolled state', because it is more descriptive of what is actually being done.

function setScrolledState(selector) {
    let lastScrollPosition = window.scrollY;
    const scrolledStateElement = document.querySelector(selector);

    window.addEventListener('scroll', () => {
       header.classList.toggle('scrolled--top', window.scrollY == 0);
       header.classList.toggle('scrolled--up', window.scrollY < lastScrollPosition);
       header.classList.toggle('scrolled--down', window.scrollY > lastScrollPosition);
       
       lastScrollPosition = window.scrollY;
    }
}

//Now add this to any element you like, in my example the header
setScrolledState('.header');

Libraries you download can often do more that this, such as taking into account offsets before triggering, but most of the time all you need is the above function.

In my case, the header is always positioned "sticky" with a "top" value of 0. On down I use CSS transforms to translate the entire element up along the Y-axis so it disappears out of view. When you scroll up, the element is translated down again and a box-shadow is added. When you then reach the top, the box-shadow is removed. Both box-shadow and transform can be animated with a transition so the whole thing looks smooth.

Custom React Hook

React hooks allow you to use React features outside of components. This allows you to extract logic from components to reusable functions.

Here is what the previous functionality looks like as a React hook:

import { useEffect, useState } from "react";

export function useScrollState() {
    const [scrollState, setScrollState] = useState('scrollstate-top');
    const [lastScrollPosition, setLastScrollPosition] = useState(0);

    const calculateScrollState = () => {
        if(window.scrollY == 0) {
            setScrollState('scrollstate--top');
        } else if(window.scrollY > lastScrollPosition) {
            setScrollState('scrollstate--down');
        } else {
            setScrollState('scrollstate--up');
        }

        setLastScrollPosition(window.scrollY);
    }

    useEffect(() =>  {
        window.addEventListener('scroll', calculateScrollState);
        return () =>  {
            window.removeEventListener('scroll', calculateScrollState);
        }
    });

    return 'scrollstate ' + scrollState;
}

There is a bit more to it than the vanilla version, but the logic remains the same. Rather that adding a class to an element though, we use state to update the current scrolled-state and return this value from the custom hook.