Skip to content

Add class active on scroll. Vanilla JS

I’m new to vanilla js. I have a navbar with links to sections. I want to make the class active as soon as the section becomes active. If there are no active section, then remove the active class. Found such a script, but there is one drawback. If I am in an inactive section, the active class will remain with the previous active section.

const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('.forJS');
  function changeLinkState() {
    let index = sections.length;

    while(--index && window.scrollY + 50 < sections[index].offsetTop) {}

    links.forEach((link) => link.classList.remove('active'));
    links[index].classList.add('active');
  }

changeLinkState();
window.addEventListener('scroll', changeLinkState);
section{
height:100vh;
scroll-y:auto;
}
.nav-link.active{
  color: red;
}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet"/>
<body>
<header class="fixed-top">
  <nav class="navbar navbar-expand-lg navCustom">
    <div class="container">

          <ul class="navbar-nav justify-content-center">
            <li class="nav-item">
              <a class="nav-link" href="#main">Main</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="#about">About us</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="#portfolio">Portfolio</a>
            </li>
            <li class="nav-item">
              <a class="nav-link" href="#contacts">Contacts</a>
            </li>
          </ul>
    </div>
  </nav>
</header>

<section class="forJS text-center">Some info 1</section>
<section class="forJS text-center">Some info 2</section>
<section class="forJS text-center">Some info 3</section>
<section class="text-center">Some info 4</section>
<section class="text-center">Some info 5</section>
<section class="text-center">Some info 6</section>
<section class="text-center">Some info 7</section>
<section class="text-center">Some info 8</section>
<section class="text-center">Some info 9</section>
<section class="forJS text-center">Some info 10</section>
</body>

P.S.Look at the last line, there is changeLinkState. Should it be without parentheses ()? And inside while is empty, why?

Answer

The most minimal change you can make using the current design to implement the desired functionality is testing the section’s height to ensure it’s visible instead of unconditionally adding the active class to the nearest navigation link as in the current code.

if (window.scrollY - sections[index].offsetHeight < 
      sections[index].offsetTop) {
  links[index].classList.add('active');
}

Instead of:

links[index].classList.add('active');

You can tweak the cutoff point with an offset like scrollY + 50 but hardcoding the number here seems non-ideal.

Full code:

const links = document.querySelectorAll('.nav-link');
const sections = document.querySelectorAll('.forJS');

function changeLinkState() {
  let index = sections.length;

  while (--index && window.scrollY + 50 < sections[index].offsetTop) {}

  links.forEach((link) => link.classList.remove('active'));

  // add the active class if within visible height of the element
  if (scrollY - sections[index].offsetHeight <
        sections[index].offsetTop) {
    links[index].classList.add('active');
  }
}

changeLinkState();
window.addEventListener('scroll', changeLinkState);
section {
  height: 100vh;
}

.nav-link.active {
  color: red;
}

section {
  border: 1px solid #555;
}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/js/bootstrap.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta2/dist/css/bootstrap.min.css" rel="stylesheet" />

<body>
  <header class="fixed-top">
    <nav class="navbar navbar-expand-lg navCustom">
      <div class="container">
        <ul class="navbar-nav justify-content-center">
          <li class="nav-item">
            <a class="nav-link" href="#main">Main</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#about">About us</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#portfolio">Portfolio</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="#contacts">Contacts</a>
          </li>
        </ul>
      </div>
    </nav>
  </header>

  <section class="forJS text-center">Some info 1</section>
  <section class="forJS text-center">Some info 2</section>
  <section class="forJS text-center">Some info 3</section>
  <section class="text-center">Some info 4</section>
  <section class="text-center">Some info 5</section>
  <section class="text-center">Some info 6</section>
  <section class="text-center">Some info 7</section>
  <section class="text-center">Some info 8</section>
  <section class="text-center">Some info 9</section>
  <section class="forJS text-center">Some info 10</section>
</body>

Your other questions were addressed in the comments but I’ll reiterate the answers here:

  • No parentheses are used on changeLinkState because we’re passing the function object itself to the callback to be invoked later. If we invoked it like changeLinkState(), we’d wind up passing undefined into the callback and prematurely firing the handler, as explained here.
  • while is empty because its block that manipulates the termination condition (i.e. --index) is merged into the condition as shorthand, as described here.

Beyond that, there are multiple issues with the design I’ll remark on briefly and leave as an exercise to the reader:

  • The bootstrap layout spreads the sidebar header across the whole page, so there’s probably unintentional overlap between the header and the element. If the header had a background, content would be occluded. I’d revisit the structure here to ensure multiple, non-overlapping columns or a flow layout was used.
  • <section> tags should be in a parent container.
  • CSS properties shouldn’t be camelCased. forJS is not a particularly clear class name.
  • scroll-y:auto; is an invalid CSS property. Perhaps you meant overflow-y: auto;.
  • The strategy of firing the scroll event listener and iterating the sections is somewhat primitive. Check out throttling and consider refactoring to use an Intersection Observer.