One-page Progress Navigation

A fixed top navigation for single-page layouts where a pill-shaped indicator slides to highlight the section currently in the viewport. Powered by GSAP ScrollTrigger — each section registers a trigger at 50% of the viewport, and the indicator animates to the matching nav button via CSS transitions.

navigationgsapscrolltriggerone-pageprogressanchor

Setup — External Scripts

External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>

Code

HTML
html
<nav class="progress-nav">
  <div class="progress-nav__inner">
    <a href="#top" class="progress-nav__logo">
      <!-- your logo SVG -->
    </a>
    <div class="progress-nav__wrapper">
      <div data-progress-nav-list="" class="progress-nav__list">
        <div class="progress-nav__indicator"></div>
        <div data-progress-nav-target="#top" class="progress-nav__btn is--before"></div>
        <a data-progress-nav-target="#introduction" href="#introduction" class="progress-nav__btn">
          <span class="progress-nav__btn-text">1. Intro</span>
          <span class="progress-nav__btn-text is--duplicate">1. Intro</span>
        </a>
        <a data-progress-nav-target="#concept" href="#concept" class="progress-nav__btn">
          <span class="progress-nav__btn-text">2. Concept</span>
          <span class="progress-nav__btn-text is--duplicate">2. Concept</span>
        </a>
        <a data-progress-nav-target="#product" href="#product" class="progress-nav__btn">
          <span class="progress-nav__btn-text">3. Product</span>
          <span class="progress-nav__btn-text is--duplicate">3. Product</span>
        </a>
        <a data-progress-nav-target="#result" href="#result" class="progress-nav__btn">
          <span class="progress-nav__btn-text">4. Result</span>
          <span class="progress-nav__btn-text is--duplicate">4. Result</span>
        </a>
        <div data-progress-nav-target="#bottom" class="progress-nav__btn is--after"></div>
      </div>
    </div>
    <a href="#bottom" class="progress-nav__contact-btn">
      <span class="progress-nav__btn-text">Get in touch</span>
      <span class="progress-nav__btn-text is--duplicate">Get in touch</span>
    </a>
  </div>
</nav>

<!-- Anchor sections -->
<section id="top" data-progress-nav-anchor="" class="section-resource">
  <h2 class="section-resource__h2">Top</h2>
</section>
<section id="introduction" data-progress-nav-anchor="" class="section-resource is--flipped">
  <h2 class="section-resource__h2">Introduction</h2>
</section>
<section id="concept" data-progress-nav-anchor="" class="section-resource">
  <h2 class="section-resource__h2">Concept</h2>
</section>
<section id="product" data-progress-nav-anchor="" class="section-resource is--flipped">
  <h2 class="section-resource__h2">Product</h2>
</section>
<section id="result" data-progress-nav-anchor="" class="section-resource">
  <h2 class="section-resource__h2">Result</h2>
</section>
<section id="bottom" data-progress-nav-anchor="" class="section-resource is--flipped">
  <h2 class="section-resource__h2">Bottom</h2>
</section>
CSS
css
.progress-nav {
  width: 100%;
  padding: 2em;
  position: fixed;
  top: 0;
  left: 0;
}

.progress-nav__inner {
  justify-content: space-between;
  align-items: center;
  display: flex;
  position: relative;
}

.progress-nav__logo {
  color: inherit;
  text-decoration: none;
}

.progress-nav__logo-svg {
  width: 8em;
}

.progress-nav__wrapper {
  background-color: #c9cce0;
  border-radius: 50em;
  padding: .5em;
}

.progress-nav__list {
  border-radius: 50em;
  justify-content: flex-start;
  align-items: center;
  display: flex;
  position: relative;
  overflow: hidden;
}

.progress-nav__indicator {
  z-index: 2;
  background-color: #fff;
  border-radius: 50em;
  width: 2.5em;
  height: 2.5em;
  position: absolute;
  left: -2.5em;
  transition: all 1.2s cubic-bezier(0.16, 1, 0.3, 1);
}

.progress-nav__btn {
  z-index: 3;
  cursor: pointer;
  color: inherit;
  justify-content: center;
  align-items: center;
  height: 2.5em;
  padding-left: 1em;
  padding-right: 1em;
  text-decoration: none;
  display: flex;
  position: relative;
  overflow: hidden;
}

.progress-nav__btn.is--before {
  z-index: 1;
  width: 2.5em;
  height: 2.5em;
  padding-left: 0;
  padding-right: 0;
  position: absolute;
  right: 100%;
}

.progress-nav__btn.is--after {
  z-index: 1;
  width: 2.5em;
  height: 2.5em;
  padding-left: 0;
  padding-right: 0;
  position: absolute;
  left: 100%;
}

.progress-nav__btn-text {
  white-space: nowrap;
  justify-content: center;
  align-items: center;
  height: 100%;
  font-size: 1.125em;
  font-weight: 500;
  display: flex;
  transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1);
  transform: translateY(0%) rotate(0.001deg);
}

.progress-nav__btn-text.is--duplicate {
  position: absolute;
  top: 100%;
}

.progress-nav__btn:hover .progress-nav__btn-text,
.progress-nav__contact-btn:hover .progress-nav__btn-text {
  transform: translateY(-100%) rotate(0.001deg);
}

.progress-nav__contact-btn {
  color: #fff;
  background-color: #2d336b;
  border-radius: 50em;
  height: 3.5em;
  padding-left: 1.5em;
  padding-right: 1.5em;
  text-decoration: none;
  position: relative;
  overflow: hidden;
}

.section-resource {
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  display: flex;
}

.section-resource.is--flipped {
  color: #fff;
  background-color: #7886c7;
}

.section-resource__h2 {
  font-size: 5em;
  font-weight: 500;
  line-height: 1;
}
JavaScript
javascript
gsap.registerPlugin(ScrollTrigger);

function initProgressNavigation() {
  const navProgress = document.querySelector('[data-progress-nav-list]');
  if (!navProgress) return;

  let indicator = navProgress.querySelector('.progress-nav__indicator');
  if (!indicator) {
    indicator = document.createElement('div');
    indicator.className = 'progress-nav__indicator';
    navProgress.appendChild(indicator);
  }

  function updateIndicator(activeLink) {
    const parentWidth = navProgress.offsetWidth;
    const parentHeight = navProgress.offsetHeight;
    const parentRect = navProgress.getBoundingClientRect();
    const linkRect = activeLink.getBoundingClientRect();

    const leftPercent = ((linkRect.left - parentRect.left) / parentWidth) * 100;
    const topPercent = ((linkRect.top - parentRect.top) / parentHeight) * 100;
    const widthPercent = (activeLink.offsetWidth / parentWidth) * 100;
    const heightPercent = (activeLink.offsetHeight / parentHeight) * 100;

    indicator.style.left = leftPercent + '%';
    indicator.style.top = topPercent + '%';
    indicator.style.width = widthPercent + '%';
    indicator.style.height = heightPercent + '%';
  }

  const progressAnchors = gsap.utils.toArray('[data-progress-nav-anchor]');

  progressAnchors.forEach((progressAnchor) => {
    const anchorID = progressAnchor.getAttribute('id');

    const activate = () => {
      const activeLink = navProgress.querySelector(`[data-progress-nav-target="#${anchorID}"]`);
      if (!activeLink) return;
      navProgress.querySelectorAll('[data-progress-nav-target]').forEach(sib => {
        sib.classList.remove('is--active');
      });
      activeLink.classList.add('is--active');
      updateIndicator(activeLink);
    };

    ScrollTrigger.create({
      trigger: progressAnchor,
      start: '0% 50%',
      end: '100% 50%',
      onEnter: activate,
      onEnterBack: activate,
    });
  });
}

document.addEventListener('DOMContentLoaded', () => {
  initProgressNavigation();
});

Attributes

NameTypeDefaultDescription
data-progress-nav-listattributeContainer for all nav buttons and the indicator pill. The JS queries this element to measure positions and move the indicator.
data-progress-nav-targetstring (e.g. "#introduction")Set on each nav button. Value must start with # and match the id of the corresponding section. The .is--before and .is--after ghost buttons use "#top" and "#bottom" to ensure the indicator enters and exits smoothly.
data-progress-nav-anchorattributeSet on each content section. The JS collects all elements with this attribute and creates a ScrollTrigger for each one.

Notes

  • Requires GSAP and ScrollTrigger loaded via CDN before the script runs.
  • Each data-progress-nav-target value must begin with # and exactly match the section id.
  • The #top and #bottom ghost button targets keep the indicator off-screen when no named section is active.
  • The indicator position is calculated in percentages so it works correctly if the nav resizes on window resize.
  • No mobile-specific layout is included — the script works at all sizes; only styling needs to be adapted for smaller screens.

Guide

How the indicator moves

When a ScrollTrigger fires (section crosses the 50% viewport line), the script finds the nav button whose data-progress-nav-target matches the section id, measures its position relative to the list container, and sets left/top/width/height on the indicator as percentages. The CSS transition (1.2s spring ease) handles the smooth slide between buttons.

Ghost buttons (.is--before and .is--after)

The first and last buttons in the list are invisible ghost elements positioned outside the visible list area (right: 100% and left: 100%). When the #top or #bottom sections are active, the indicator slides off-screen to the left or right, creating a clean enter/exit effect.

Adding or removing sections

Add a new section with a unique id and data-progress-nav-anchor, then add a matching nav button with data-progress-nav-target="#your-id". No JS changes are needed — the script discovers all anchors automatically.

Hover text slide

Each button contains two .progress-nav__btn-text spans: one in normal position and one absolutely placed at top: 100% (.is--duplicate). On hover, both translate up by 100%, sliding the visible text out and the duplicate in.