Banded Scrollspy Rail
A section rail that never flickers, because only one section can be "active" at a time by construction. The IntersectionObserver root is shrunk to a 7%-tall band across the middle of the scroller, so a section registers only while it crosses that line. The active tick grows from 16 to 32 pixels — a size change, legible in peripheral vision, rather than a tint change that is not. No animation library: one observer, one CSS width transition.
Banded Scrollspy Rail
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/banded-scrollspy-rail.jsonNo runtime dependencies.
Prompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
2 files, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| sections | { id: string; label: string }[] | — | Ordered list. Each id must match a data-scrollspy attribute inside the root. |
| rootRef | RefObject<HTMLElement | null> | — | The scroll container holding the sections. Omit for the document. |
| bandTop | number | = 38 | Percent of the root trimmed from the top to form the band. |
| bandBottom | number | = 55 | Percent trimmed from the bottom. The two must sum to under 100. |
| endId | string | — | Section after which the rail retires. Usually the footer. |
| hideAt | number | = 0.75 | Fraction down the root the end marker must pass before the rail hides. |
| attribute | string | = 'data-scrollspy' | Attribute used to find sections inside the root. |
| onActiveChange | (id: string | null) => void | — | Fires once per active-section change. Held in a ref, so it needs no memo. |
| className | string | — | Merged onto the nav element via cn. |
How it works
The technique in order, not the code in order.
One IntersectionObserver watches every
[data-scrollspy]element withrootMargin: "-38% 0px -55% 0px". Trimming 38% off the top and 55% off the bottom leaves a 7%-tall detection band (100 − 38 − 55), positioned just below centre so a section becomes active while you are reading it rather than while it is still arriving.Entries maintain a
Setof ids currently inside the band. The active id is resolved by scanning the authoredsectionsorder for the first member of that set — not by readingentries[0], whose order depends on how the observer batched the callback and therefore on scroll direction.When the set is empty — a gap between sections, or a header taller than the band — the previous answer is held rather than cleared, so the rail never blanks between sections.
A second observer watches the element named by
endIdwithrootMargin: "0px 0px -25% 0px", which makes "intersecting" mean exactly "its top has climbed above 75% of the root". That retires the whole rail, so it is not hovering over a footer with nothing left to navigate.The indicator itself is a
<span>whose inlinewidthflips between 16 and 32 px with a 260 ms CSS transition. The only JavaScript per scroll event is aSetmutation and at most onesetState.Section elements are collected into an id → element
Mapby walking the attribute once, rather than running a[data-scrollspy="id"]selector per section — that form needs escaping for any id containing punctuation and silently matches nothing when the escaping is wrong.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| bandTop / bandBottom | = 38 / 55 | Leaves a 7% band. Widen it and two sections are inside at once on a tall screen, which is the flicker this design exists to remove. Narrow it below about 4% and a fast flick can jump the band entirely between two observer callbacks, leaving the rail on the previous section. The asymmetry (38 vs 55) puts the band below centre, where the eye is while reading. |
| ACTIVE_WIDTH / IDLE_WIDTH | = 32 / 16 | A 2x length change is detectable without looking directly at it. Equal widths with only a colour change is invisible in peripheral vision, which defeats the point of a rail you are not supposed to be looking at. |
| TRANSITION_MS | = 260 | Long enough that the width change reads as movement and draws the eye, short enough that it has finished before the next section can reach the band during normal scrolling. |
| hideAt | = 0.75 | Fraction of the root the end marker must pass before the rail retires. At 1.0 the rail is still on screen while the footer is being read; below about 0.6 it disappears while the last section is still active. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The rail still tracks the active section and still changes width — that is state, not decoration. Every transition is set to none, so the tick snaps between widths and the labels appear instantly, and click-to-navigate uses behavior: "auto" rather than a smooth scroll.
Both IntersectionObservers are disconnected on unmount and on any option change. An observer holds a strong reference to every element it watches, so skipping this pins the unmounted section tree in memory and keeps firing setState on a dead component.
Scroll slowly: the active tick changes exactly once as each section crosses the middle of the screen, with no flicker at the boundary and no blank state in the gaps. The active tick is visibly twice as long as the others. Scroll to the end and the whole rail fades out. Hover it and the labels appear; tab into it and they appear too. With reduced motion on, the tick still moves but snaps rather than eases.
Pitfalls
- Observing with the default full-height root. Three sections intersect at once on a tall screen and the active tick flickers between them on every callback. Debouncing hides the symptom and adds lag; shrinking the root removes the ambiguity.
- Taking the active section from
entries[0]. The callback batches entries in an order that depends on which boundaries were crossed, so the same scroll direction can yield a different winner between two frames. Resolve against the authored order instead. - Clearing the active state when nothing is intersecting. Any gap taller than the band — a spacer, a full-bleed image, a header — blanks the rail mid-scroll and it reads as broken.
- Signalling the active section with colour only. On a hairline tick in the corner of the eye a tint change is imperceptible; a width change is not, and it also survives a monochrome or high-contrast rendering.
- Rebuilding the observers on every render.
sectionsis usually a fresh array literal from the parent, so keying the effect on it disconnects and reconnects two observers on every keystroke elsewhere in the page. Key on the joined id list and read the array from a ref. - Forgetting
observer.disconnect(). IntersectionObserver keeps a strong reference to every observed element, so an undisconnected observer pins the whole unmounted section tree in memory and keeps callingsetStateon a dead component. - Scrolling with
window.scrollToon click when the sections live in a container.scrollIntoViewwalks up and scrolls whichever ancestor actually holds the overflow, which is the only version that works in both cases.
Notes
- No GSAP and no ScrollTrigger: the whole component is two IntersectionObservers and a CSS transition, which is why it is classed
csscost and can be used dozens of times on a page. - The labels are always in the DOM at
opacity: 0and reveal on hover or keyboard focus, so the rail is operable and readable without a pointer.