Scrollspy with ‘just JavaScript’

Native JavaScript to implement ScrollSpy (change active menu based on scrolled location on page)

Bootstrap has an awesome feature baked into it for highlighting the ‘active’ item in a page’s navbar based on what section of the page a user is currently viewing — as the user scrolls down the page, the menu automagically updates, adding the active class to the appropriate nav item.

But what if you’re not using Bootstrap?

Well, there are a metric ton of npm packages that’ll help you accomplish the same thing if you’re using jQuery, or Angular, or React, etc, etc, etc.

But what if you just want to code up the functionality in plain ‘ole JavaScript?

That’s where I found myself this week and it turns out to be surprisingly simple.

There are only really four things you need to know:

How to get some JavaScript loaded up when your page loads:

To do this, add an ‘event listener’ that watches for the ‘DOMContentLoaded’ event

document.addEventListener('DOMContentLoaded', function(){
// the JavaScript you want to run after the page loads
}, false);

How to get the DOM elements you want to either change or check the position of:

To do this, use document.querySelectorAll and pass in something that uniquely identifies the HTML elements you want to capture. In my case, I gave the elements specific classnames then queried based on those classes.

const sections = document.querySelectorAll(".template__section");
const menu_links = document.querySelectorAll(".template__nav-item a");

How to check whether a DOM element you care about is in the currently viewable area of the browser window:

To do this, first you need to capture ‘scroll events’ so you know when the user is scrolling and can then check the user’s position in the page to see which nave element it corresponds to.

window.addEventListener("scroll", () => {
// check position and update nav

Checking the position is a bit mathy, but not too mathy — you just check the current scroll position and compare it to the positions of the sections of the page to find which one matches up. It helps to add a bit of ‘wiggle room’ at the top of a section to ensure your section header is well within the user’s view when that section registers as the current one.

const current = sections.length - [...sections].reverse().findIndex((section) => window.scrollY >= section.offsetTop - sectionMargin ) - 1

That’s a lot happening in one statement so I’ll break it down a bit.

window.scrollY — is the user’s current scroll position

section.offsetTop — corresponds to how far down each particular section is

sectionMargin — is that ‘wiggle room’ I was talking about, to allow a bit of space at the top of a section when it’s considered the active section.

[...sections] — is a bit of ES6 magic that takes a list of DOM nodes (what we got from the queries we did above, and turns them into a regular JavaScript array we can use all normal array methods on

reverse() — just reverses the array. Why reverse is the important part here — by reversing, when we use findIndex() next it will find the last section on the page that’s past the current window scroll. Not reversed, it’d find the first section of the page ‘first’ every single time and our scrolling magic wouldn’t work.

findIndex() — finds the first index in an array that matches the given condition.

How to add or remove a class from a DOM element:

To do this, use classList.add and classList.remove, which are methods available on the DOM nodes we queried earlier.

const makeActive = (link) => menu_links[link].classList.add("active");
const removeActive = (link) => menu_links[link].classList.remove("active");
const removeAllActive = () => [...Array(sections.length).keys()].forEach((link) => removeActive(link));

If you want to see the full (minimal) demo I put together as a proof of concept, you can see it on CodePen here: