Pattern
Current section
The rail always shows where you are in a long page, and lets you jump.
01
Watch it
02
Rules
- Which section is current
- The last section whose top has passed 30% of the viewport height.
- Marker
- One 2px bar in the marker colour slides to the current entry: transform and height, 250ms on the system ease. The link gets
aria-current="true". - Tablet
- The rail becomes a sticky number row; the current number gets a 2px underline instead of the sliding bar.
- Phone
- No rail. Sections still carry their numbers.
- Without JavaScript
- The rail is a plain list of anchor links; no marker.
- Performance
- One scroll listener, throttled to animation frames; recalculated on resize.
03
Code
From site/assets/js/site.js, lines 14 to 47.
// ------------------------------------------------------------ rail marker
const rail = document.querySelector('.rail');
if (rail) {
const links = [...rail.querySelectorAll('a[href^="#"]')];
const sections = links.map((a) => document.getElementById(a.hash.slice(1))).filter(Boolean);
const mark = rail.querySelector('.rail-mark');
const list = rail.querySelector('ol'); // the marker sits in the rail, the links in the list
const set = (i) => {
links.forEach((a, j) => (j === i ? a.setAttribute('aria-current', 'true') : a.removeAttribute('aria-current')));
if (mark && links[i]) {
mark.hidden = false;
mark.style.setProperty('--rail-y', links[i].offsetTop + (list ? list.offsetTop : 0) + 'px');
mark.style.setProperty('--rail-h', links[i].offsetHeight + 'px');
}
};
const pick = () => {
// The active section is the last one whose top has passed 30% of the viewport.
const line = innerHeight * 0.3;
let i = 0;
sections.forEach((s, j) => {
if (s.getBoundingClientRect().top <= line) i = j;
});
set(i);
};
let queued = false;
addEventListener('scroll', () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; pick(); });
}, { passive: true });
addEventListener('resize', pick, { passive: true });
pick();
}