IntersectionObserver with multiple isIntersecting headers, not working correctly

Viewed 760

I have a bunch of text that is split into headings <h2> and <h3> and paragraphs <p>. The <h2> have an ID assigned to them that matches a navigation pane, which is just a simple table of contents in a list form with links <ul> and <li> and <a>.

For example: <h2 id="test-1">Test 1</h2> Correlates to: <li><a href="#test-1">Test 1</a></li>

The goal is to use the IntersectionObserver API to highlight the table of contents when the users scrolls down the page.

For example, the .active-content class and the .active-content-left-border class both get applied in the relevant places to make the table of contents highlighted and turned green.

Please observe the following Javascript. As you can see if the heading <h2> is intersecting isIntersecting, it adds the class to the element, which altars the colour and adds a border.

However, the issue I'm experiencing is that if there are more than one heading <h2> on the screen, it throws the whole script off.

const tableofcontents = document.querySelectorAll("#sub-content-right-table-of-contents-list li a");
const tableofcontentsleftborders = document.querySelectorAll("#sub-content-right-table-of-contents-list li")
const headings = document.querySelectorAll("#sub-content-main-text h2")
const observer = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        const index = Array.from(headings).indexOf(entry.target)
        if (entry.isIntersecting) {
            tableofcontents[index].classList.add("active-content")
            tableofcontentsleftborders[index].classList.add("active-content-left-border")
        } else if (!entry.isIntersecting) {
            tableofcontents.forEach(tableofcontent => {
                tableofcontent.classList.remove("active-content")
            })
            tableofcontentsleftborders.forEach(tableofcontentsleftborder => {
                tableofcontentsleftborder.classList.remove("active-content-left-border")
            })
        }
    })
}, {
    threshold: 0.25,
})
headings.forEach(heading => {
    observer.observe(heading)
})

So my question is, if there are two headings on the screen - how do I make it so that the very top header is the one that stipulating the addition of the class?

Codepen can be found here

2 Answers

This solution does not rely on Root margin like my other answer. It uses an array to keep track of which sections are on screen, it then uses this state to only apply 'active' styling to the first section that is on screen.

you may wish to customise the threshold value, or animation transition times.

The downside of relying on threshold value is that it is a %. e.g. threshold: 0.25 is triggered when passing 25% of the section is on screen.

If there is a lot of variety in section heights it can be difficult to calibrate.

keep in mind when setting a high threshold value that it is possible for a section to be so long that the required % of a section will not be able to fit on screen and therefore never trigger the IntersectionObserver

h2 {
  text-align: left;
  font-family: Ubuntu, sans-serif;
  font-size: 200%;
  margin-top: 30px;
  margin-bottom: 15px;
}

h3 {
  text-align: left;
  font-family: Ubuntu, sans-serif;
  font-size: 150%;
  margin-top: 15px;
  margin-bottom: 7.5px;
}

p {
  font-size: 1.1875rem;
  line-height: 1.47368;
  margin-top: 24px;
}

body {
  margin: 0;
  font-family: Lora, serif;
  height: 100%;
  width: 100%;
}

html {
  scroll-behavior: smooth;
}

#left-navigation-positioner {
  position: relative;
  height: 100%;
}

.content {
  float: left;
  width: 70%;
}

.sub-content-main-wrapper {
  float: left;
  width: 70%;
  background-color: #d8dde0;
}

#sub-content-main-text {
  margin-left: 40px;
  margin-top: 50px;
  margin-right: 40px;
  margin-bottom: 100vh;
}

#sub-content-right-wrapper {
  float: left;
  width: 30%;
}

#sub-content-right-table-of-contents-list {
  position: fixed;
}

#sub-content-right-table-of-contents-list li {
  border-left: 1px solid black;
  list-style: none;
  padding: 10px;
}

li a:hover,
.active-content {
  border-color: rgb(255, 231, 231);
  color: green;
  transition: all 400ms ease-in-out;
}

.active-content-left-border {
  border-left: 3px solid blue !important;
  transition: all 400ms ease-in-out;
}

#section {
  border: 3px solid indigo;
}
<!doctype html>

<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body >
  <div class="content">
    <div class="sub-content-main-wrapper">
      <div id="sub-content-main-text">
        <div id="section">
          <h2 id="test-1">Test 1</h2>
          <div style="height:100px;"></div>
        </div>
        <div id="section">
          <h2 id="test-2">Test 2</h2>
          <div style="height:600px;"></div>
        </div>
        <div id="section">
          <h2 id="test-3">Test 3</h2>
          <div style="height:400px;"></div>
        </div>
        <div id="section">
          <h2 id="test-4">Test 4</h2>
          <div style="height:200px;"></div>
        </div>
        <div id="section">
          <h2 id="test-5">Test 5</h2>
          <div style="height:200px;"></div>
        </div>
        <div id="section">
          <h2 id="test-6">Test 6</h2>
          <div style="height:200px;"></div>
        </div>
      </div>
    </div>
    <div id="sub-content-right-wrapper">
      <div id="sub-content-right-table-of-contents">
        <ul id="sub-content-right-table-of-contents-list">
          <li><a href="#test-1">Test 1</a></li>
          <li><a href="#test-2">Test 2</a></li>
          <li><a href="#test-3">Test 3</a></li>
          <li><a href="#test-4">Test 4</a></li>
          <li><a href="#test-5">Test 5</a></li>
          <li><a href="#test-6">Test 6</a></li>
        </ul>
      </div>
    </div>
  </div>
  <script id="Update-current-table-of-contents">
    const tableofcontents = document.querySelectorAll("#sub-content-right-table-of-contents-list li a");
    const tableofcontentsleftborders = document.querySelectorAll("#sub-content-right-table-of-contents-list li")
    const sections = document.querySelectorAll("#section");
    let sectionsActiveList = Array.from({length: sections.length}, isActive => false);
    const setOnScreen = (entry, index) => {
      sectionsActiveList[index] = entry.isIntersecting;
      const firstActiveSeciton = sectionsActiveList.findIndex(section => section === true); 
      sectionsActiveList.forEach((section, index) => {
        if (index === firstActiveSeciton) {
          tableofcontents[index].classList.add("active-content")
          tableofcontentsleftborders[index].classList.add("active-content-left-border")
        } else {
          tableofcontents[index].classList.remove("active-content")
          tableofcontentsleftborders[index].classList.remove("active-content-left-border")
        }
      })
    }
    const options = {
        threshold: 0.25
    };
    sections.forEach((section, index) => {
      const observer = new IntersectionObserver(([entry]) => setOnScreen(entry, index), options)
      observer.observe(section)
    })
  </script>
</body>

</html>

As you have highlighted, the issue with your current approach is:

  • More than one heading can be visible in the viewport at the same time.
  • The accompanying paragraphs are so long that the heading is not visible at all times.

I built something with similar requirements to this but using React.js and unfortunately the rootMargin is not working as expected in this demo on my Mac but it does work on my android phone (both use Chrome). (I can't see any issues though, it is possibly due to the demo using a cross-origin-domain target, so maybe it would work if loaded into a browser?)

In theory, the rootMargin can be used to narrow the bounding box of the intersection root so it is effectively a horizontal line positioned on the screen. It works like a margin so this would fix the line 25% from the top of the screen:

rootMargin: "-25% 0px -75% 0px",

On the demo I have drawn a line in red to demonstrate where the invisible line is placed. It is supposed to trigger the callback function when a section intersects that line (hence only highlighting one link at a time) and NOT when ever a section enters/exits the viewport.

set threshold: 0 so that any amount of intersection triggers the call back.

This version also observes the whole section (not just the title) in order to keep the navigation accurate for sections taller than the viewport.

h2 {
  text-align: left;
  font-family: Ubuntu, sans-serif;
  font-size: 200%;
  margin-top: 30px;
  margin-bottom: 15px;
}

h3 {
  text-align: left;
  font-family: Ubuntu, sans-serif;
  font-size: 150%;
  margin-top: 15px;
  margin-bottom: 7.5px;
}

p {
  font-size: 1.1875rem;
  line-height: 1.47368;
  margin-top: 24px;
}

body {
  margin: 0;
  font-family: Lora, serif;
  height: 100%;
  width: 100%;
}

html {
  scroll-behavior: smooth;
  height: 100%
}

#left-navigation-positioner {
  position: relative;
  height: 100%;
}

.content {
  float: left;
  width: 70%;
}

.sub-content-main-wrapper {
  float: left;
  width: 70%;
  background-color: #d8dde0;
}

#sub-content-main-text {
  margin-left: 40px;
  margin-top: 50px;
  margin-right: 40px;
}

#sub-content-right-wrapper {
  float: left;
  width: 30%;
}

#sub-content-right-table-of-contents-list {
  position: fixed;
}

#sub-content-right-table-of-contents-list li {
  border-left: 1px solid black;
  list-style: none;
  padding: 10px;
}

li a:hover,
.active-content {
  border-color: rgb(255, 231, 231);
  color: green;
  transition: all 100ms ease-in-out;
}

.active-content-left-border {
  border-left: 3px solid blue !important;
  transition: all 100ms ease-in-out;
}

#section {
  border: 3px solid indigo;
}

hr {
    position: fixed;
    top: 25%;
    width: 100%;
    border: 2px solid rgba(255, 0, 0, 0.5);
}
<!doctype html>

<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
</head>

<body>
  <hr/>
  <div class="content">
    <div class="sub-content-main-wrapper">
      <div id="sub-content-main-text">
        <div id="section">
          <h2 id="test-1">Test 1</h2>
          <div style="height:800px;"></div>
        </div>
        <div id="section">
          <h2 id="test-2">Test 2</h2>
          <div style="height:600px;"></div>
        </div>
        <div id="section">
          <h2 id="test-3">Test 3</h2>
          <div style="height:400px;"></div>
        </div>
        <div id="section">
          <h2 id="test-4">Test 4</h2>
          <div style="height:200px;"></div>
        </div>
        <div id="section">
          <h2 id="test-5">Test 5</h2>
          <div style="height:200px;"></div>
        </div>
        <div id="section">
          <h2 id="test-6">Test 6</h2>
          <div style="height:200px;"></div>
        </div>
      </div>
    </div>
    <div id="sub-content-right-wrapper">
      <div id="sub-content-right-table-of-contents">
        <ul id="sub-content-right-table-of-contents-list">
          <li><a href="#test-1">Test 1</a></li>
          <li><a href="#test-2">Test 2</a></li>
          <li><a href="#test-3">Test 3</a></li>
          <li><a href="#test-4">Test 4</a></li>
          <li><a href="#test-5">Test 5</a></li>
          <li><a href="#test-6">Test 6</a></li>
        </ul>
      </div>
    </div>
  </div>
  <script id="Update-current-table-of-contents">
    const tableofcontents = document.querySelectorAll("#sub-content-right-table-of-contents-list li a");
    const tableofcontentsleftborders = document.querySelectorAll("#sub-content-right-table-of-contents-list li")
    const sections = document.querySelectorAll("#section")
    const setOnScreen = (entry, index) => {
      // console.log('Test',index + 1, entry.isIntersecting)
      if (entry.isIntersecting) {
          tableofcontents[index].classList.add("active-content")
          tableofcontentsleftborders[index].classList.add("active-content-left-border")
        } else {
          tableofcontents[index].classList.remove("active-content")
          tableofcontentsleftborders[index].classList.remove("active-content-left-border")
        }
    }
    const options = {
        rootMargin: "-25% 0px -75% 0px",
        threshold: 0
    };
    sections.forEach((section, index) => {
      const observer = new IntersectionObserver(([entry]) => setOnScreen(entry, index), options)
      observer.observe(section)
    })
  </script>
</body>

</html>

Related