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.
Setup — External Scripts
<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
<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>.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;
}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
| Name | Type | Default | Description |
|---|---|---|---|
| data-progress-nav-list | attribute | — | Container for all nav buttons and the indicator pill. The JS queries this element to measure positions and move the indicator. |
| data-progress-nav-target | string (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-anchor | attribute | — | Set 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.