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:
- update a global variable (global to the ECMA script module!) with the current scroll position (
window.scrollY
) in regular intervals - 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