By combining the CSS position property value of sticky and using the Intersection Observer JavaScript API we can create a light weight tabbed scrollable section of content.

The Intersection Observer is typically used for things like infinite scroll, lazy loading images and animations. It works better that the conventional JavaScript method of using .offset() to get an elements page coordinates. This method continually runs on the main thread while the scroll event is listening. The Intersection Observer is much more efficient, as it only runs when the element enters the view port. It also gives us more control on observing elements.

sticky - CSS

It allows elements to stick on a page only when scrolling down. It works similar to fixed and is a mix between relative and fixed.

First we give a div an id or class using position: sticky; with a threshold of top: 0. We need top: to specify the space between the top of the element and the top of the viewport.

<style>
#tabs {
background: grey;
height: 30px;
position: sticky;
top: 20px;
}
</style>
<div id="tabs"></div>

That's it! This is how easy it is to use sticky positioning.

When you put the sticky position inside another element the behavior changes. The tabs will now only be sticky while in that container. Make the container a height that is taller than the tabs to work.

Example

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

This is one of the main benefits of the position: sticky; because you are able to display information about an element only when it is in view.

Tabbed navigation - HTML

Lets create a quick unordered list tab navigation and give the first one the active class. Then add the href id's that will link to the content sections.

<div id="tabs-container" class="example">
<ul id="tabs">
<li class="tab active"><a href="#one">One</a></li>
<li class="tab"><a href="#two">Two</a></li>
<li class="tab"><a href="#three">Three</a></li>
<li class="tab"><a href="#four">Four</a></li>
</ul>
</div>
ul#tabs {
display: flex;
justify-content: space-around;
list-style: none;
background: grey;
position: sticky;
top: 0;
opacity: .7;
padding: 15px;
}
li.tab a {
color: white;
text-decoration: none;
}
#tabs li.active {
text-decoration: underline;
}

Create the corresponding sections for each content section.

<div class="tab-section">
<span id="one" class="anchoroffset"></span>
<h2>One</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.</p>
</div>

Anchor offset - CSS

.anchoroffset {
display: block;
position: relative;
top: -100px;
visibility: hidden;
}

We correspond the href# with the span id which will give us the needed space for the drop anchor, and because it is position: relative; it will not cause any visible issues.

  <ul id="tabs">
<li class="tab active"><a href="#one">One</a></li>
<li class="tab"><a href="#two">Two</a></li>
<li class="tab"><a href="#three">Three</a></li>
<li class="tab"><a href="#four">Four</a></li>
</ul>

At this point we have our sticky tab navigation with scrolling content that when we click the nav, the drop anchor will take us to it's section. We also have a anchor offset span that will push the content down and keep it below the sticky nav.

Intersection Observer - JavaScript

Now we can use Intersection Observer with custom JavaScript to add and remove the active class. This will coordinate the section with the navigation while scrolling.

Use a JavaScript array to store the indexOf tab sections that will correspond with the index of the tab nav.

const index = Array.from(tabSections).indexOf(entry.target)

Tell the Intersection Observer to only look at the top of the screen by observing the top of the section div. Observing 300px below the top of the screen to get below the sticky nav.

if (entry.boundingClientRect.top < 280)  {
}

Now we have to set the Intersection Observer options threshold to only look when the element passes 50% on the way down and 0% on the way up. This will offset the element percent to be active as the bottom starts to enter back on the screen. The rootMargin is set to start at 280px below the top of the screen and corresponds with the boundingClientRect.top.

threshold: [.5, 0],
rootMargin: "-300px 0px 0px 0px",

Using a combination of the threshold: [.5, 0], rootMargin: "-280px 0px 0px 0px" and entry.boundingClientRect.top < 300 we can isolate just the section for the top of the screen.

Example

One

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

Two

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

Three

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

Four

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.

Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.

Final code.

<style type="text/css">
.anchoroffset {
display: block;
position: relative;
top: -100px;
visibility: hidden;
}
ul#tabs {
display: flex;
justify-content: space-around;
list-style: none;
background: grey;
position: sticky;
top: 0;
opacity: .7;
padding: 15px;
}
li.tab a {
color: white;
text-decoration: none;
}
#tabs li.active {
text-decoration: underline;
}
</style>

<div id="tabs-container" class="example">
<ul id="tabs">
<li class="tab active"><a href="#one">One</a></li>
<li class="tab"><a href="#two">Two</a></li>
<li class="tab"><a href="#three">Three</a></li>
<li class="tab"><a href="#four">Four</a></li>
</ul>
<div class="tab-section">
<span id="one" class="anchoroffset"></span>
<h2>One</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.</p>
</div>
<div class="tab-section">
<span id="two" class="anchoroffset"></span>
<h2>Two</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.</p>
</div>
<div class="tab-section">
<span id="three" class="anchoroffset"></span>
<h2>Three</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.</p>
</div>
<div class="tab-section">
<span id="four" class="anchoroffset"></span>
<h2>Four</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam posuere quam sit amet pellentesque iaculis. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Vivamus id lectus eget nibh fringilla commodo. Mauris bibendum purus nec orci aliquet tincidunt.</p>
</div>
</div>

<script type="text/javascript">
const tabs = document.querySelectorAll(".tab")
const tabSections = document.querySelectorAll(".tab-section")

const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.boundingClientRect.top < 280) {
console.log(entry)
const index = Array.from(tabSections).indexOf(entry.target)
tabs.forEach(tab => {
tab.classList.remove("active")
})
tabs[index].classList.add("active")
}
})
}, {
threshold: [.5, 0],
rootMargin: "-280px 0px 0px 0px",
})

tabSections.forEach(sections => {
observer.observe(sections)
})
</script>

Conclusion

This looks to be a good viable solution for creating dynamic scrolling content with a tabbed navigation. There is definitely a balance of option settings to get just the right effect here as the Intersection Observer naturally looks at the whole screen. We just need to observe the top of the screen. So by playing with these settings you can get just the right desired effect.

You will also see from the reference examples that this layout is easier to create if you are only showing one section at a time and using the full view port for each section.

References