Pixelwoelkchen

Display a Navigation Menu When the User Scrolls Rapidly

(2024-07-18, randomchars42)

Try it yourself!

Scroll this page really fast and a menu will appear.

Scroll it slowly and you will be able to do some high precision scrolling.

Why?

I've been working on a (progressive) web app that needed to run on a tablet in horizontal mode to facilitate complex (medical) documentation with minimal typing -> Papiertiger.

The aim was to have the least intrusive interface possible as the app content was complex enough.

So there was no (mind) space for a navigation menu but one would be needed as more and more sections of documentation phrases appeared.

The solution:

If the user scrolled slowly to look for a specific item let him scroll. But if the user flicked rapidly to reach a distant section let a popup menu appear letting him jump to the desired section.

How?

We are going to need to tap into the scroll-event and attach an event handler that does two things:

  1. update a global variable (global to the ECMA script module!) with the current scroll position (window.scrollY) in regular intervals
  2. open the navigation if the user scrolled a certain distance in the last interval

The combination of interval duration and distance determines how readily the navigation will open. This will depend on the use case. I found an update interval of 250 ms and a scroll distance of 650 px to be usable with both touch screen and mouse wheel.

Some dirty code:

The whole code can be downloaded in this gist: https://gist.github.com/randomchars42/4ab53dd97c669d3a4bda175cfe7bcaf9

A working demo can be found here:

https://www.pixelwoelkchen.de/apps/demos/scrollnavigation/

/**
 * scrollnav.ts
 */
// get the scroll position
// if you scroll inside another element use
// document.getElementById('ELEMENT').scrollTop;
let scrollPosition: number|null = window.scrollY;

export const init = (): void => {
    console.log('initiating ...');

    window.onscroll = (): void => {
        // get the current position
        let currentPosition: number = window.scrollY;
        // calculate the distance using an absolute number
        // allowing the user to acivate the menu by scrolling up
        // or down 
        //
        // this will open the modal while the scrolling continues
        // imagine a mighty swipe up with your finger on a tablet:
        // the modal will open, the scroll continues and the
        // distance will trigger a second modal
        // -> to prevent this we will set the `scrollPosition` to `null`
        // and thus deactivate the mechanism
        // note: in a previous version I set it to `-1` but apparantly
        // scrollTop can be negative on iOS / Apple devices
        if (scrollPosition !== null &&
            Math.abs(scrollPosition - currentPosition) > 650) {
            scrollPosition = null;
            showNav();
        }
        setTimeout((): void => {
            // only update the position while the modal is not visible
            // or else two or more modals could be triggered
            if (scrollPosition === null) {
                return;
            }
            // get a "fresh" position, don't use the one from 250 ms ago
            scrollPosition = window.scrollY;
        }, 250);
    };

    document.querySelectorAll('.nav_link').forEach((link: Element): void => {
        link.onclick = (event: Event): void => {
            // jump to the target manually
            // or else `hideNav()` would be triggered first and re-enable
            // the scroll menu, then the link would lead the site to jump to
            // it's target triggering a scroll event - in turn triggering
            // the menu...
            scrollPosition = null;
            hideNav();
            const id: string = new URL(link.href).hash.substring(1);
            document.getElementById(id)!.scrollIntoView();
            // re-enable the mechanism for the scroll navigation
            scrollPosition = window.scrollY;
            event.preventDefault();
        };
    });

    // hide the modal if the user clicks anywhere else on the screen
    document.getElementById('NavModal')!.onclick = hideNav;

    console.log('UI initiated.')
}

const showNav = (): void => {
    document.getElementById('NavModal')!.classList.remove('hidden');
};

const hideNav = (): void => {
    document.getElementById('NavModal')!.classList.add('hidden');
};

init();
/**
 * scrollnav.js
 */
// get the scroll position
// if you scroll inside another element use
// document.getElementById('ELEMENT').scrollTop;
let scrollPosition = window.scrollY;

export const init = () => {
    console.log('initiating ...');

    window.onscroll = () => {
        // get the current position
        let currentPosition = window.scrollY;
        // calculate the distance using an absolute number
        // allowing the user to acivate the menu by scrolling up
        // or down 
        //
        // this will open the modal while the scrolling continues
        // imagine a mighty swipe up with your finger on a tablet:
        // the modal will open, the scroll continues and the
        // distance will trigger a second modal
        // -> to prevent this we will set the `scrollPosition` to `-1`
        // and thus deactivate the mechanism
        // note: in a previous version I set it to `-1` but apparantly
        // scrollTop can be negative on iOS / Apple devices
        if (scrollPosition !== null &&
            Math.abs(scrollPosition - currentPosition) > 650) {
            scrollPosition = null;
            showNav();
        }
        setTimeout(() => {
            // only update the position while the modal is not visible
            // or else two or more modals could be triggered
            if (scrollPosition === null) {
                return;
            }
            // get a "fresh" position, don't use the one from 250 ms ago
            scrollPosition = currentPosition;
        }, 250);
    };

    document.querySelectorAll('.nav_link').forEach((link) => {
        link.onclick = (event) => {
            // jump to the target manually
            // or else `hideNav()` would be triggered first and re-enable
            // the scroll menu, then the link would lead the site to jump to
            // it's target triggering a scroll event - in turn triggering
            // the menu...
            scrollPosition = null;
            const id = new URL(link.href).hash.substring(1);
            document.getElementById(id).scrollIntoView();
            hideNav();
            // re-enable the mechanism for the scroll navigation
            scrollPosition = window.scrollY;
            event.preventDefault();
        };
    });

    // hide the modal if the user clicks anywhere else on the screen
    document.getElementById('NavModal').onclick = hideNav;

    console.log('UI initiated.')
}

const showNav = () => {
    document.getElementById('NavModal').classList.remove('hidden');
};

const hideNav = () => {
    document.getElementById('NavModal').classList.add('hidden');
};

init();

The HTML skeleton:

<!DOCTYPE html>
<!-- index.html -->
<html lang="en-GB">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <meta name="color-scheme" content="dark light">
    <title data-i18n-key="page_title">DEMO! Scroll Navigation</title>
    <link rel="stylesheet" href="style.css" />
    <link rel="icon" href="icon.svg" />
    <script type="module" src="js/scrollnav.js"></script>
</head>
<body>
    <noscript data-i18n-key="page_noscript">To use this site, please enable JavaScript.</noscript>
    <main>
        <h1 id="winnie-the-pooh">WINNIE-THE-POOH</h1>
        <p>Taken from: <a href="https://www.gutenberg.org/files/67098/67098-h/67098-h.htm">
        The Project Gutenberg eBook of Winnie-the-Pooh, by A. A. Milne</a></p>
        <h2 id="chapter-1">CHAPTER 1 - IN WHICH WE ARE INTRODUCED TO WINNIE-THE-POOH AND SOME
        BEES, AND THE STORIES BEGIN</h2>
        <p>[...]</p>
        <h2 id="chapter-2">CHAPTER 2 - IN WHICH POOH GOES VISITING AND GETS INTO A TIGHT PLACE</h2>
        <p>[...]</p>
        <h2 id="chapter-3">CHAPTER 3 - IN WHICH POOH AND PIGLET GO HUNTING AND NEARLY CATCH
        A WOOZLE</h2>
        <p>[...]</p>
        <h2 id="chapter-4">CHAPTER 4 - IN WHICH EEYORE LOSES A TAIL AND POOH FINDS ONE</h2>
        <p>[...]</p>
        <h2 id="chapter-5">CHAPTER 5 - IN WHICH PIGLET MEETS A HEFFALUMP</h2>
        <p>[...]</p>
    </main>
    <div id="NavModal" class="hidden">
        <div id="Nav">
            <a href="#winnie-the-pooh" class="nav_link">WINNIE-THE-POOH</a>
            <a href="#chapter-1" class="nav_link">Chapter 1</a>
            <a href="#chapter-2" class="nav_link">Chapter 2</a>
            <a href="#chapter-3" class="nav_link">Chapter 3</a>
            <a href="#chapter-4" class="nav_link">Chapter 4</a>
            <a href="#chapter-5" class="nav_link">Chapter 5</a>
        </div>
    </div>
</body>
</html>

Add some primitive CSS:

/* style.css */

main {
    width: 40rem;
    margin: auto;
}

#NavModal {
    position: fixed;
    z-index: 1000000;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    overflow: auto;
    background-color: rgb(0,0,0);
    background-color: rgba(0,0,0,0.4);

    display: flex;
    justify-content: center;
    align-items: center;
}

#Nav {
    background-color: #FFFFFF;
    padding: 2rem;
    width: 40%;
}

#Nav a {
    display: block;
}

.hidden {
    display: none !important;
}

That's it. Pure javascript / TypeScript. No libraries / polyfills / ponyfills.

If you've got comments, thoughts or improvements leave them under the gist:

https://gist.github.com/randomchars42/4ab53dd97c669d3a4bda175cfe7bcaf9