Search code examples
javascripthtmljquerygsapscrolltrigger

Update links dynamically once user scrolls past div with ID


I have a nav menu which I want to update whenever a user scrolls past the section it's referencing.

For example, if the user scrolls past #section-1, I want .configurator__menu-link with the href="#section-1" to become orange.

However, my current logic doesn't work, and I'm unsure why. Initially I thought it may be that my scroll position is being calculated incorrectly because of my scrollTrigger. But, having commented out the code that pins the section, the results are still the same.

Demo here:

$(function() {

  gsap.registerPlugin(ScrollTrigger);

  let container = document.querySelector(".configurator");
  let pin = document.getElementById("configurator-pin");
  let freeroam = document.getElementById("configurator-freeroam");

  ScrollTrigger.create({
    trigger: container,
    start: "top",
    endTrigger: freeroam,
    end: () => `bottom 0%+=${pin.offsetHeight}`,
    invalidateOnRefresh: true,
    pin: pin,
    markers: true
  });

  ScrollTrigger.create({
    trigger: container,
    start: "top",
    endTrigger: freeroam,
    end: () => `bottom 0%+=${pin.offsetHeight}`,
    invalidateOnRefresh: true,
    pin: ".configurator__header",
    markers: true,
    pinSpacing: false
  });


  // UPDATE CLASS ON SCROLL

  var menu = $(".configurator__menu"),
    menu_link = $(".configurator__menu-link"),
    active_class = "configurator__menu-link--active";

  $(window).scroll(function() {
    var scrollTop = $(document).scrollTop();
    var anchors = menu.find(menu_link);
    for (var i = 0; i < anchors.length; i++) {
      if (scrollTop > $(anchors[i]).offset().top - 50 && scrollTop < $(anchors[i]).offset().top + $(anchors[i]).height() - 50) {
        $('.configurator__menu-link[href="#' + $(anchors[i]).attr('id') + '"]').addClass(active_class);
      } else {
        $('.configurator__menu-link[href="#' + $(anchors[i]).attr('id') + '"]').removeClass(active_class);
      }
    }
  });

});
.spacer {
  height: 200px;
  background: lightblue;
}

.configurator {
  overflow: hidden;
}

.configurator__header {
  padding: 30px 0;
  background-color: #FFFFFF;
  z-index: 99;
  width: 100%!important;
  max-width: 100%!important;
}

.configurator__options {
  background: lightblue;
  padding: 30px;
}

.configurator__options-inner {
  padding: 60px 30px;
}

.configurator__options-inner-section{
  margin-bottom: 60px;
}

.configurator__images img {
  width: 100%;
}

.footer {
  height: 300px;
  background-color: lightblue;
}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/ScrollTrigger.min.js"></script>

<div class="spacer"></div>

<section class="configurator">

  <div class="container">
    <div class="row">
      <div class="col-12">
        <header class="configurator__header">
          <nav class="configurator__menu justify-content-between">
            <a class="configurator__menu-link" href="#section-1">Section 1</a>
            <a class="configurator__menu-link" href="#section-2">Section 2</a>
          </nav>
        </header>
      </div>
    </div>
  </div>

  <div class="container-fluid px-0">
    <div class="row">

      <div class="col-6">
        <div class="configurator__images" id="configurator-pin">
          <img src="https://via.placeholder.com/840x514?text=Image" alt="test" />
        </div>
      </div>

      <div class="col-6">
        <div class="configurator__options">
          <div class="configurator__options-inner" id="configurator-freeroam">
            <div class="configurator__options-inner-section" id="section-1">
              <h2>Section 1</h2>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
            </div>
            <div class="configurator__options-inner-section" id="section-2">
              <h2>Section 2</h2>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing

            </div>
          </div>
        </div>
      </div>

    </div>
  </div>

</section>

<footer class="footer"></footer>

In reply to inwerpsel's answer:

Many thanks for showing me this approach, and in hindsight, agree that it's much better than firing code after every scroll event.

I've tested your approach, and whilst it does work, wondering if there's room to optimise it further?

The first thing I'm noticing is that whenever the next section is in view, even slightly, then that nav link will become active. For example, if I'm scrolling down #section-1 and #section-2 is in view, even a little, then that link for #section-2 is active. Whereas from a UX perspective, the majority of the content on their screen is still from #section-1.

Is there a way to delay the active state unless it's 50% in view?

See demo in GIF here

In the GIF you can see that #section-3 isn't even in view, yet its link was active.

Same issue when scrolling back up. Even though most of a section is in view, it will make the previous link active, which is confusing UX wise.

Any way to optimise this further?

Code for updated demo:

$(function() {

  var menu = $(".configurator__menu"),
    menu_link = $(".configurator__menu-link"),
    active_class = "configurator__menu-link--active";

  const activateMenuItem = id => {
    $('.configurator__menu-link').removeClass(active_class);
    $(`.configurator__menu-link[href="#${id}"]`).addClass(active_class);

  }
  const options = {
   // No root option provided means check against the window.
    threshold: 0,
  }

  activateVisibleSectionMenu = (entries) => {
    entries.forEach(entry => {
      console.log(entry);
      if (entry.isIntersecting) {
        // Should be as it becomes the first element on the page.
        activateMenuItem(entry.target.id);
      }
    });
  }
  const observer = new IntersectionObserver(activateVisibleSectionMenu, options);
  const sections = document.querySelectorAll('.configurator__options-inner-section');
  sections.forEach(section => observer.observe(section));


  let container = document.querySelector(".configurator");
  let pin = document.getElementById("configurator-pin");
  let freeroam = document.getElementById("configurator-freeroam");
  ScrollTrigger.create({
    trigger: container,
    start: "top",
    endTrigger: freeroam,
    end: () => `bottom 0%+=${pin.offsetHeight}`,
    invalidateOnRefresh: true,
    pin: pin,
    // markers: true
  });

  ScrollTrigger.create({
    trigger: container,
    start: "top",
    endTrigger: freeroam,
    end: () => `bottom 0%+=${pin.offsetHeight}`,
    invalidateOnRefresh: true,
    pin: ".configurator__header",
    // markers: true,
    pinSpacing: false
  });


});
.spacer {
  height: 200px;
  background: lightblue;
}

.configurator {
  overflow: hidden;
}

.configurator__header {
  padding: 30px 0;
  background-color: #FFFFFF;
  z-index: 99;
  width: 100%!important;
  max-width: 100%!important;
}

.configurator__options {
  background: lightblue;
  padding: 30px;
}

.configurator__options-inner {
  padding: 60px 30px;
}

.configurator__options-inner-section {
  margin-bottom: 60px;
}

.configurator__images img {
  width: 100%;
}

.footer {
  height: 300px;
  background-color: lightblue;
}

.configurator__menu-link--active {
  background: yellow !important;
}
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/ScrollTrigger.min.js"></script>

<div class="spacer"></div>

<section class="configurator">

  <div class="container">
    <div class="row">
      <div class="col-12">
        <header class="configurator__header">
          <nav class="configurator__menu justify-content-between">
            <a class="configurator__menu-link" href="#section-1">Section 1</a>
            <a class="configurator__menu-link" href="#section-2">Section 2</a>
                    <a class="configurator__menu-link" href="#section-3">Section 3</a>
          </nav>
        </header>
      </div>
    </div>
  </div>

  <div class="container-fluid px-0">
    <div class="row">

      <div class="col-6">
        <div class="configurator__images" id="configurator-pin">
          <img src="https://via.placeholder.com/840x514?text=Image" alt="test" />
        </div>
      </div>

      <div class="col-6">
        <div class="configurator__options">
          <div class="configurator__options-inner" id="configurator-freeroam">
            <div class="configurator__options-inner-section" id="section-1">
              <h2>Section 1</h2>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                            Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
            </div>
            <div class="configurator__options-inner-section" id="section-2">
              <h2>Section 2</h2>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
            </div>
                        <div class="configurator__options-inner-section" id="section-3">
              <h2>Section 3</h2>
              Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                                        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                                        Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
              in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
            </div>
          </div>
        </div>
      </div>

    </div>
  </div>

</section>

<footer class="footer"></footer>


Solution

  • With an intersection observer you can do this much more efficiently than with a scroll listener. It will only fire when an element either stops or starts intersecting.

    const activateMenuItem = id => {
      $('.configurator__menu-link').removeClass(active_class);
      $(`.configurator__menu-link[href="#${id}"]`).addClass(active_class);
    
    }
    const options = {
    // No root option provided means check against the window.
      threshold: .2,
    }
    
    activateVisibleSectionMenu = (entries) => {
      entries.forEach(entry => {
        console.log(entry);
        if (entry.intersectionRatio > opts.threshold) {
          // Should be as it becomes the first element on the page.
          activateMenuItem(entry.target.id);
        }
      });
    }
    const observer = new IntersectionObserver(activateVisibleSectionMenu, options);
    const sections = document.querySelectorAll('.configurator__options-inner-section');
    sections.forEach(section => observer.observe(section));
    

    Better performance

    With a scroll listener would will be firing continuously on every small scroll movement, while only rarely it actually needs to update something. Even if you'd mark it as passive it's still running way more often than it needs to.

    Working example

    May need some fine tuning of threshold to get nicer behavior, but it seems to work quite well on your example.

    $(function() {
    
      var menu = $(".configurator__menu"),
        menu_link = $(".configurator__menu-link"),
        active_class = "configurator__menu-link--active";
    
      const activateMenuItem = id => {
        $('.configurator__menu-link').removeClass(active_class);
        $(`.configurator__menu-link[href="#${id}"]`).addClass(active_class);
    
      }
      const options = {
       // No root option provided means check against the window.
        threshold: 0,
      }
    
      activateVisibleSectionMenu = (entries) => {
        entries.forEach(entry => {
          console.log(entry);
          if (entry.isIntersecting) {
            // Should be as it becomes the first element on the page.
            activateMenuItem(entry.target.id);
          }
        });
      }
      const observer = new IntersectionObserver(activateVisibleSectionMenu, options);
      const sections = document.querySelectorAll('.configurator__options-inner-section');
      sections.forEach(section => observer.observe(section));
    
    
      let container = document.querySelector(".configurator");
      let pin = document.getElementById("configurator-pin");
      let freeroam = document.getElementById("configurator-freeroam");
      ScrollTrigger.create({
        trigger: container,
        start: "top",
        endTrigger: freeroam,
        end: () => `bottom 0%+=${pin.offsetHeight}`,
        invalidateOnRefresh: true,
        pin: pin,
        markers: true
      });
    
      ScrollTrigger.create({
        trigger: container,
        start: "top",
        endTrigger: freeroam,
        end: () => `bottom 0%+=${pin.offsetHeight}`,
        invalidateOnRefresh: true,
        pin: ".configurator__header",
        markers: true,
        pinSpacing: false
      });
    
    
      // UPDATE CLASS ON SCROLL
    
      /*$(window).scroll(function() {
        var scrollTop = $(document).scrollTop();
        var anchors = menu.find(menu_link);
        for (var i = 0; i < anchors.length; i++) {
          if (scrollTop > $(anchors[i]).offset().top - 50 && scrollTop < $(anchors[i]).offset().top + $(anchors[i]).height() - 50) {
            $('.configurator__menu-link[href="#' + $(anchors[i]).attr('id') + '"]').addClass(active_class);
          } else {
            $('.configurator__menu-link[href="#' + $(anchors[i]).attr('id') + '"]').removeClass(active_class);
          }
        }
      });*/
    
    });
    .spacer {
      height: 200px;
      background: lightblue;
    }
    
    .configurator {
      overflow: hidden;
    }
    
    .configurator__header {
      padding: 30px 0;
      background-color: #FFFFFF;
      z-index: 99;
      width: 100%!important;
      max-width: 100%!important;
    }
    
    .configurator__options {
      background: lightblue;
      padding: 30px;
    }
    
    .configurator__options-inner {
      padding: 60px 30px;
    }
    
    .configurator__options-inner-section {
      margin-bottom: 60px;
    }
    
    .configurator__images img {
      width: 100%;
    }
    
    .footer {
      height: 300px;
      background-color: lightblue;
    }
    
    .configurator__menu-link--active {
      background: yellow !important;
    }
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/gsap.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.9.1/ScrollTrigger.min.js"></script>
    
    <div class="spacer"></div>
    
    <section class="configurator">
    
      <div class="container">
        <div class="row">
          <div class="col-12">
            <header class="configurator__header">
              <nav class="configurator__menu justify-content-between">
                <a class="configurator__menu-link" href="#section-1">Section 1</a>
                <a class="configurator__menu-link" href="#section-2">Section 2</a>
              </nav>
            </header>
          </div>
        </div>
      </div>
    
      <div class="container-fluid px-0">
        <div class="row">
    
          <div class="col-6">
            <div class="configurator__images" id="configurator-pin">
              <img src="https://via.placeholder.com/840x514?text=Image" alt="test" />
            </div>
          </div>
    
          <div class="col-6">
            <div class="configurator__options">
              <div class="configurator__options-inner" id="configurator-freeroam">
                <div class="configurator__options-inner-section" id="section-1">
                  <h2>Section 1</h2>
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
                  in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
                </div>
                <div class="configurator__options-inner-section" id="section-2">
                  <h2>Section 2</h2>
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor
                  in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipiscing
    
                </div>
              </div>
            </div>
          </div>
    
        </div>
      </div>
    
    </section>
    
    <footer class="footer"></footer>

    Fix for inconsistent behavior

    The threshold is quite useless, an item may not fit the whole container so we can't rely on any value higher than 0. Even if 0.2 would work for all, it would result in different offsets depending on the element height.

    A fix for this is to not use the entire element, but its first heading. It seems a bit hacky but it's exactly what Github does on their API documentation.

    You can find this code by enabling "break on attribute modifications" on one of the sidebar menu items (the ones that get highlighted when you scroll). I won't bother to post it other than an image because it's partially minified code.

    Specifically this part is the fix I'm referring to.

    return Array.from(document.querySelectorAll("h2, h3")).forEach((function(t) {
        e.observe(t)
    }
    

    enter image description here

    They also use rootMargin, but I couldn't get that to work on your code.