Scroll Spy Nav
As you scroll through the page, the highlight in its nav glides to the link for the section you’re reading, so you always know where you are. Click a link and the page scrolls smoothly to that section.
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-spy-navto your nav. Each link’shref(like#setup) points at its section. - Give each section the matching
id. The nav can run across or down, and the sections can be on the page or in a scrolling box.
Options
Add any of these attributes to the same element.
data-style | pill behind the link or a thin bar beside or under it. Default pill. |
data-color | Colour of the highlight. Default #6a0dad. |
data-line | Where in the view a section counts as current, from 0 (top) to 1 (bottom). Default 0.3. |
data-duration | How long the highlight takes to glide. Default 450 ms. |
data-autoplay | Inside a scrolling box, it visits each section in turn while in view, pausing when someone scrolls. Handy for demos. |
The current link gets aria-current="location" and the class ssn-on, and the nav fires a spychange event (e.detail.index). At the very bottom of the page the last link lights up, even if its section is short. A nav that scrolls sideways keeps the current link in view.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll Spy Nav: as you scroll through a page, the highlight in its nav
// glides to the link for the section you're reading. Click a link and the
// page scrolls smoothly to that section.
// Usage: <nav data-scroll-spy-nav>
// <a href="#intro">Intro</a>
// <a href="#setup">Setup</a> …
// </nav>
// <section id="intro">…</section> <section id="setup">…</section>
// Each child of the nav is a link; its href (or data-target) points at its
// section. Works for a nav laid out across or down, and for sections inside
// a scrolling box as well as the page.
// Optional: data-style ("pill" behind the link or a "bar" beside/under it,
// default pill), data-color (highlight colour, default #6a0dad), data-line
// (where in the view a section counts as current, 0 top to 1 bottom,
// default 0.3), data-duration (ms, default 450), data-autoplay (inside a
// scrolling box, it scrolls to each section in turn while in view, pausing
// when someone scrolls; handy for demos).
// The current link gets aria-current="location" and the class "ssn-on";
// the nav fires a "spychange" event (e.detail.index).
var DEFAULTS = { style: 'pill', color: '#6a0dad', line: 0.3, duration: 450 };
var css =
'.ssn{position:relative;isolation:isolate}' +
'.ssn-link{position:relative;z-index:1;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .3s}' +
'.ssn-mark{position:absolute;left:0;top:0;z-index:0;pointer-events:none;will-change:transform,width,height}' +
'.ssn-pill .ssn-mark{border-radius:999px}' +
'.ssn-pill .ssn-link.ssn-on{color:#fff}' +
'.ssn-bar .ssn-mark{border-radius:3px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if ((o === 'auto' || o === 'scroll') && p.scrollHeight > p.clientHeight) return p;
}
return null;
}
// A target from href="#id" or data-target, looked up near the nav first so
// two copies of a demo on one page don't mix their sections up.
function find(nav, sel) {
if (!sel || sel === '#') return null;
for (var p = nav.parentElement; p; p = p.parentElement) {
var m = p.querySelector(sel);
if (m) return m;
}
return null;
}
function place(s, i) {
var l = s.links[i];
var r = { x: l.offsetLeft, y: l.offsetTop, w: l.offsetWidth, h: l.offsetHeight };
if (s.bar) {
// A bar beside a column of links, or under a row of them.
if (s.vertical) { r.w = 3; } else { r.y += r.h - 3; r.h = 3; }
}
return r;
}
function draw(s) {
var g = s.g;
s.mark.style.transform = 'translate(' + g.x + 'px,' + g.y + 'px)';
s.mark.style.width = g.w + 'px';
s.mark.style.height = g.h + 'px';
}
function select(nav, i, instant) {
var s = nav._ssn;
if (i === s.index && !instant) return;
var changed = i !== s.index;
s.index = i;
s.links.forEach(function (l, k) {
l.classList.toggle('ssn-on', k === i);
if (s.a11y) { if (k === i) l.setAttribute('aria-current', 'location'); else l.removeAttribute('aria-current'); }
});
var to = place(s, i);
if (s.anim) s.anim.pause();
if (instant || reduced()) {
s.g.x = to.x; s.g.y = to.y; s.g.w = to.w; s.g.h = to.h;
draw(s);
} else {
s.anim = anime.animate(s.g, { x: to.x, y: to.y, w: to.w, h: to.h, duration: s.duration, ease: 'outBack(1.3)', onRender: function () { draw(s); } });
}
// Keep the current link in view in a nav that scrolls sideways.
if (nav.scrollWidth > nav.clientWidth + 1) {
var l = s.links[i];
var want = l.offsetLeft - (nav.clientWidth - l.offsetWidth) / 2;
nav.scrollTo({ left: want, behavior: instant || reduced() ? 'auto' : 'smooth' });
}
if (changed && !instant) nav.dispatchEvent(new CustomEvent('spychange', { bubbles: true, detail: { index: i } }));
}
function current(s) {
var box = s.box;
var top = box ? box.getBoundingClientRect().top : 0;
var h = box ? box.clientHeight : window.innerHeight;
var y = box ? box.scrollTop : window.scrollY;
var max = box ? box.scrollHeight - box.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
// At the very bottom, the last section wins even if it's short.
if (max > 0 && y >= max - 2) return s.targets.length - 1;
var line = top + h * s.line;
var pick = 0;
s.targets.forEach(function (t, k) {
if (t && t.getBoundingClientRect().top <= line) pick = k;
});
return pick;
}
function scrollTo(s, i, ms) {
var t = s.targets[i];
if (!t) return;
var box = s.box;
var from = box ? box.scrollTop : window.scrollY;
var to = from + t.getBoundingClientRect().top - (box ? box.getBoundingClientRect().top : 0) - (s.gap || 0);
var max = box ? box.scrollHeight - box.clientHeight : document.documentElement.scrollHeight - window.innerHeight;
to = Math.max(0, Math.min(max, to));
cancelAnimationFrame(s.raf);
if (reduced() || !ms) { if (box) box.scrollTop = to; else window.scrollTo(0, to); return; }
var t0 = performance.now();
var frame = function (now) {
var p = Math.min(1, (now - t0) / ms);
var e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
var v = from + (to - from) * e;
if (box) box.scrollTop = v; else window.scrollTo(0, v);
if (p < 1) s.raf = requestAnimationFrame(frame);
};
s.raf = requestAnimationFrame(frame);
}
function setup(nav) {
var d = nav.dataset;
var links = Array.prototype.slice.call(nav.children);
var targets = links.map(function (l) { return find(nav, l.getAttribute('data-target') || l.getAttribute('href')); });
var first = targets.filter(Boolean)[0];
if (!first) return;
var a11y = !nav.closest('[aria-hidden="true"]');
var bar = (d.style || DEFAULTS.style) === 'bar';
nav.classList.add('ssn', bar ? 'ssn-bar' : 'ssn-pill');
var mark = document.createElement('span');
mark.className = 'ssn-mark';
mark.setAttribute('aria-hidden', 'true');
mark.style.background = d.color || DEFAULTS.color;
nav.appendChild(mark);
links.forEach(function (l) { l.classList.add('ssn-link'); });
var s = nav._ssn = {
links: links, targets: targets, mark: mark, bar: bar, a11y: a11y, index: -1, anim: null, raf: 0,
g: { x: 0, y: 0, w: 0, h: 0 },
box: scroller(first),
line: d.line !== undefined ? +d.line : DEFAULTS.line,
duration: +d.duration || DEFAULTS.duration,
vertical: false
};
var measure = function () {
s.vertical = links.length > 1 && Math.abs(links[1].offsetTop - links[0].offsetTop) > Math.abs(links[1].offsetLeft - links[0].offsetLeft);
// If the nav sticks above the sections inside the same box, leave room for it.
var stuck = s.box && s.box.contains(nav) && !s.vertical;
s.gap = stuck ? nav.offsetHeight + 8 : 8;
};
measure();
select(nav, current(s), true);
var queued = false;
var onScroll = function () {
if (queued) return;
queued = true;
requestAnimationFrame(function () { queued = false; select(nav, current(s)); });
};
(s.box || window).addEventListener('scroll', onScroll, { passive: true });
links.forEach(function (l, k) {
l.addEventListener('click', function (e) {
if (!targets[k]) return;
e.preventDefault();
idle();
select(nav, k);
scrollTo(s, k, 700);
});
});
if (window.ResizeObserver) new ResizeObserver(function () { measure(); select(nav, s.index, true); }).observe(nav);
var idleUntil = 0;
function idle() { idleUntil = Date.now() + 8000; }
if (s.box && d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var box = s.box, timer = null, step = 0, visible = false;
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
box.addEventListener(t, function () { idle(); cancelAnimationFrame(s.raf); }, { passive: true });
});
var tick = function () {
if (!visible) return;
if (Date.now() > idleUntil && !(window.matchMedia('(hover: hover)').matches && (box.matches(':hover') || nav.matches(':hover')))) {
step = (step + 1) % targets.length;
scrollTo(s, step, step === 0 ? 1100 : 800);
}
timer = setTimeout(tick, 1900);
};
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) { step = s.index; timer = setTimeout(tick, 900); }
else cancelAnimationFrame(s.raf);
}).observe(box);
}
}
function init() {
document.querySelectorAll('[data-scroll-spy-nav]').forEach(function (el) {
if (el._ssnDone) return;
el._ssnDone = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><nav data-scroll-spy-nav>
<a href="#intro">Intro</a>
<a href="#setup">Setup</a>
<a href="#usage">Usage</a>
</nav>
<section id="intro">…</section>
<section id="setup">…</section>
<section id="usage">…</section>