Scroll Rail
The page scrollbar redrawn: a spectrum thumb, section ticks, and a readout that names where you are.
npx shadcn@latest add @vibebox/scroll-railControls
<ScrollRail />About
Long marketing pages lose people; a scrollbar that knows the page helps them. A hairline rail on the right edge carries a thumb that is a window onto the spectrum (the first stop at the top of the page, the last at the bottom), a trail of the ground covered, a tick for every marked section, and a readout naming the current section with a percentage and five equalizer bars that bounce with scroll speed. Hover the rail to preview a section, click to jump, drag the thumb to travel. Touch screens get a slimmer rail whose thumb can be dragged. Ported from the VibeBox site; the sections it reads, the offsets and the native-scrollbar swap are now props.
Use it when
- A page has five or more sections worth naming.
- The site already uses the spectrum accent.
Skip it when
- The page is short (the rail hides itself when there is nothing to scroll).
- An app view scrolls inside panels rather than the page.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/scroll-rail - 3Use it:
import { ScrollRail } from "@/components/vibebox/scroll-rail"; <main> <ScrollRail /> <section className="scroll-mt-[72px]"> <p data-rail-index="01" data-rail-label="Services">01 · Services</p> … </section> </main>
Code
2 filescomponents/vibebox/scroll-rail/index.tsx
/**
* VibeBox Motion · Scroll Rail
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { SCROLL_RAIL_MEDIA, ScrollRail, TOUCH_RAIL_MEDIA, type ScrollRailProps } from "./scroll-rail.client";
/** A site's stops: its page sections after the hero, without belts. Here, not in the island, so a server layout reads the string. */
export const SITE_RAIL_SECTIONS = 'section[data-vb]:not([data-vb^="hero-"]):not([data-vb^="logos-"])';components/vibebox/scroll-rail/scroll-rail.client.tsx
/**
* VibeBox Motion · Scroll Rail
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { LazyMotion, domAnimation, m, useAnimationFrame, useMotionValue, useMotionValueEvent, useScroll, useSpring, useTransform, useVelocity } from "motion/react";
import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/lib/utils";
import { useMediaQuery, useReducedMotion } from "@/lib/vibebox/hooks";
/* The page scrollbar, redrawn as part of the site: a hairline rail whose thumb is a window onto
the spectrum (the first stop at the top of the page, the last at the bottom), a trail of the
ground covered, ticks for each marked section, and a readout that names the section and
bounces with scroll speed. Drag the thumb, click the rail, or click a tick. Touch screens get
a slimmer rail with a draggable thumb. Pointer-only and hidden from assistive technology:
keyboard, wheel and touch scrolling stay the browser's own.
Sections: any element inside `root` with data-rail-index (and optionally data-rail-label);
the tick lands on its closest <section>. */
/** Where the rail replaces the native scrollbar. */
export const SCROLL_RAIL_MEDIA = "(hover: hover) and (pointer: fine) and (forced-colors: none)";
/** Touch screens get the slimmer rail. */
export const TOUCH_RAIL_MEDIA = "(hover: none) and (forced-colors: none), (pointer: coarse) and (forced-colors: none)";
const RAIL_SPECTRUM =
"linear-gradient(180deg, var(--spectrum-1) 0%, var(--spectrum-2) 17%, var(--spectrum-3) 36%, var(--spectrum-4) 56%, var(--spectrum-5) 70%, var(--spectrum-6) 85%, var(--spectrum-7) 100%)";
const BAR_STOPS = [1, 2, 3, 4, 5];
const BAR_REST = [0.42, 0.72, 1, 0.68, 0.4];
const ACTIVATION = 0.4;
type Marker = { index: string; label: string; progress: number; activeFrom: number; element: HTMLElement };
type Metrics = { track: number; thumb: number; scrollable: number };
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
/** A section's short name: its own `data-rail-label`, else its eyebrow, else its heading. */
function sectionLabel(element: HTMLElement): string {
const named = element.dataset.railLabel ?? element.querySelector<HTMLElement>("[data-rail-label]")?.dataset.railLabel;
if (named) return named;
const eyebrow = element.querySelector<HTMLElement>('[data-slot="eyebrow"]')?.textContent?.trim();
if (eyebrow) return eyebrow;
const heading = element.getAttribute("aria-labelledby");
return (heading ? document.getElementById(heading)?.textContent : element.querySelector("h2, h3")?.textContent)?.replace(/\s+/g, " ").trim() ?? "";
}
function collectMarkers(root: string, scrollable: number, sectionOffset: number, sections?: string): Marker[] {
const container = document.querySelector<HTMLElement>(root);
if (!container || scrollable <= 0) return [];
const seen = new Set<HTMLElement>();
const markers: Marker[] = [];
const place = (element: HTMLElement, index: string, label: string) => {
const top = element.getBoundingClientRect().top + window.scrollY;
markers.push({ index, label, progress: clamp01((top - sectionOffset) / scrollable), activeFrom: clamp01((top - window.innerHeight * ACTIVATION) / scrollable), element });
};
if (sections) {
// Every matching section is a stop, numbered in page order and named by its own words.
container.querySelectorAll<HTMLElement>(sections).forEach((element, order) => place(element, String(order + 1).padStart(2, "0"), sectionLabel(element)));
return markers.sort((a, b) => a.progress - b.progress);
}
container.querySelectorAll<HTMLElement>("[data-rail-index]").forEach((label) => {
const element = (label.closest("section") as HTMLElement | null) ?? label;
if (seen.has(element)) return;
seen.add(element);
const top = element.getBoundingClientRect().top + window.scrollY;
markers.push({
index: label.dataset.railIndex ?? "",
label: label.dataset.railLabel ?? label.textContent?.trim() ?? "",
progress: clamp01((top - sectionOffset) / scrollable),
activeFrom: clamp01((top - window.innerHeight * ACTIVATION) / scrollable),
element,
});
});
return markers.sort((a, b) => a.progress - b.progress);
}
function sectionAt(markers: Marker[], progress: number) {
let found = -1;
markers.forEach((marker, index) => {
if (marker.activeFrom <= progress + 0.002) found = index;
});
return found;
}
/** Measures the page and its marked sections, and keeps the numbers current. */
function useRailMetrics(top: number, bottom: number, minThumb: number, root: string, sectionOffset: number, sections?: string) {
const [metrics, setMetrics] = useState<Metrics>({ track: 0, thumb: 0, scrollable: 0 });
const [markers, setMarkers] = useState<Marker[]>([]);
const travel = useMotionValue(0);
const trackHeight = useMotionValue(1);
const thumbHalf = useMotionValue(0);
const measure = useCallback(() => {
const view = window.innerHeight;
const doc = document.documentElement.scrollHeight;
const scrollable = Math.max(0, doc - view);
const track = Math.max(0, view - top - bottom);
const thumb = scrollable > 0 ? Math.min(track * 0.6, Math.max(minThumb, (track * view) / doc)) : track;
travel.set(Math.max(0, track - thumb));
trackHeight.set(Math.max(1, track));
thumbHalf.set(thumb / 2);
setMetrics((previous) => (previous.track === track && previous.thumb === thumb && previous.scrollable === scrollable ? previous : { track, thumb, scrollable }));
setMarkers(collectMarkers(root, scrollable, sectionOffset, sections));
}, [top, bottom, minThumb, root, sectionOffset, sections, travel, trackHeight, thumbHalf]);
useEffect(() => {
let frame = 0;
const schedule = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
};
schedule();
const settle = window.setTimeout(schedule, 600);
const observer = new ResizeObserver(schedule);
observer.observe(document.body);
window.addEventListener("resize", schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
window.clearTimeout(settle);
observer.disconnect();
window.removeEventListener("resize", schedule);
};
}, [measure]);
return { metrics, markers, travel, trackHeight, thumbHalf };
}
export type ScrollRailProps = {
/** Selector for the element whose [data-rail-index] children become ticks. */
root?: string;
/**
* Instead of [data-rail-index] markers: a selector (inside `root`) whose every match is a stop,
* numbered in page order and named by its `data-rail-label`, its eyebrow or its heading.
*/
sections?: string;
/** Where a jump lands a section (match your sections' scroll-margin-top). */
sectionOffset?: number;
/** Space above and below the rail on wide screens (clear a fixed header). */
top?: number;
bottom?: number;
/** Space above and below the rail on touch screens (clear a header and a chat launcher). */
touchTop?: number;
touchBottom?: number;
/** Readout text before the first section. */
introLabel?: string;
/** Hide the browser's scrollbar where the rail replaces it (mouse and trackpad only). */
hideNativeScrollbar?: boolean;
className?: string;
};
export function ScrollRail({
root = "main",
sections,
sectionOffset = 72,
top = 96,
bottom = 24,
touchTop = 86,
touchBottom = 96,
introLabel = "Intro",
hideNativeScrollbar = true,
className,
}: ScrollRailProps) {
const fine = useMediaQuery(SCROLL_RAIL_MEDIA);
const touch = useMediaQuery(TOUCH_RAIL_MEDIA);
return (
<LazyMotion features={domAnimation} strict>
{hideNativeScrollbar && fine && (
<style href="vb-scroll-rail" precedence="vb">
{`@media ${SCROLL_RAIL_MEDIA} { html { scrollbar-width: none; } html::-webkit-scrollbar { display: none; } }`}
</style>
)}
{fine ? (
<PointerRail root={root} sections={sections} sectionOffset={sectionOffset} top={top} bottom={bottom} introLabel={introLabel} className={className} />
) : touch ? (
<TouchRail root={root} sections={sections} sectionOffset={sectionOffset} top={touchTop} bottom={touchBottom} introLabel={introLabel} className={className} />
) : null}
</LazyMotion>
);
}
type RailProps = { root: string; sections?: string; sectionOffset: number; top: number; bottom: number; introLabel: string; className?: string };
const readoutClasses =
"pointer-events-none absolute flex h-[30px] origin-right -translate-y-1/2 items-center gap-2.5 rounded-md border border-border-strong bg-popover px-2.5 text-[0.78rem] whitespace-nowrap text-muted-foreground shadow-[0_14px_32px_-14px_rgba(0,0,0,0.5)] transition-opacity duration-200 ease-expo-out after:absolute after:top-1/2 after:-right-[5px] after:size-2 after:-translate-y-1/2 after:rotate-45 after:border-t after:border-r after:border-border-strong after:bg-popover";
function PointerRail({ root, sections, sectionOffset, top, bottom, introLabel, className }: RailProps) {
const reduced = useReducedMotion();
const zoneRef = useRef<HTMLDivElement>(null);
const { metrics, markers, travel, trackHeight, thumbHalf } = useRailMetrics(top, bottom, 36, root, sectionOffset, sections);
const [current, setCurrent] = useState(-1);
const [reached, setReached] = useState(-1);
const [preview, setPreview] = useState<number | null>(null);
const [hovering, setHovering] = useState(false);
const [dragging, setDragging] = useState(false);
const [awake, setAwake] = useState(false);
const awakeTimer = useRef(0);
const drag = useRef<{ startY: number; startScroll: number } | null>(null);
const { scrollY, scrollYProgress } = useScroll();
const thumbY = useTransform<number, number>([scrollYProgress, travel], ([progress = 0, distance = 0]) => progress * distance);
const fillY = useTransform(thumbY, (value) => -value);
const trailScale = useTransform<number, number>([thumbY, travel, trackHeight], ([y = 0, distance = 0, track = 1]) => (y + (track - distance) / 2) / Math.max(1, track));
const chipTarget = useMotionValue(0);
const previewing = useRef(false);
const chipSpring = useSpring(chipTarget, { stiffness: 700, damping: 48, mass: 0.6 });
const percent = useMotionValue("0%");
// Equalizer bars in the readout bounce with scroll speed.
const velocity = useVelocity(scrollY);
const energy = useSpring(
useTransform(velocity, (value) => Math.min(1, Math.abs(value) / 2600)),
{ stiffness: 140, damping: 22, mass: 0.5 },
);
const bar0 = useMotionValue(BAR_REST[0]!);
const bar1 = useMotionValue(BAR_REST[1]!);
const bar2 = useMotionValue(BAR_REST[2]!);
const bar3 = useMotionValue(BAR_REST[3]!);
const bar4 = useMotionValue(BAR_REST[4]!);
const bars = [bar0, bar1, bar2, bar3, bar4];
const resting = useRef(true);
useAnimationFrame((time) => {
if (reduced) return;
const amount = energy.get();
if (amount < 0.01) {
if (!resting.current) {
bars.forEach((bar, index) => bar.set(BAR_REST[index] ?? 0.5));
resting.current = true;
}
return;
}
resting.current = false;
bars.forEach((bar, index) => {
const bounce = Math.abs(Math.sin(time / 110 + index * 1.3));
bar.set((BAR_REST[index] ?? 0.5) * (1 - amount) + (0.25 + 0.75 * bounce) * amount);
});
});
const syncReadout = useCallback(
(progress: number) => {
setReached((previous) => {
const next = sectionAt(markers, progress);
return previous === next ? previous : next;
});
if (preview !== null) return;
setCurrent((previous) => {
const next = sectionAt(markers, progress);
return previous === next ? previous : next;
});
percent.set(`${Math.round(progress * 100)}%`);
},
[markers, percent, preview],
);
useMotionValueEvent(thumbY, "change", (y) => {
if (!previewing.current) chipTarget.set(y + thumbHalf.get());
});
useMotionValueEvent(scrollYProgress, "change", (progress) => {
syncReadout(progress);
setAwake(true);
window.clearTimeout(awakeTimer.current);
awakeTimer.current = window.setTimeout(() => setAwake(false), 1100);
});
useEffect(() => {
syncReadout(scrollYProgress.get());
if (!previewing.current) chipTarget.set(thumbY.get() + thumbHalf.get());
}, [chipTarget, scrollYProgress, syncReadout, thumbHalf, thumbY, metrics]);
useEffect(() => () => window.clearTimeout(awakeTimer.current), []);
const progressAt = (y: number) => clamp01((y - metrics.thumb / 2) / Math.max(1, metrics.track - metrics.thumb));
const localY = (event: ReactPointerEvent) => event.clientY - top;
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (drag.current) {
const distance = Math.max(1, metrics.track - metrics.thumb);
window.scrollTo({ top: drag.current.startScroll + ((event.clientY - drag.current.startY) * metrics.scrollable) / distance, behavior: "instant" });
return;
}
const tick = (event.target as HTMLElement).dataset.railTick;
const progress = tick !== undefined ? (markers[Number(tick)]?.progress ?? progressAt(localY(event))) : progressAt(localY(event));
previewing.current = true;
setPreview(progress);
setCurrent(sectionAt(markers, progress));
percent.set(`${Math.round(progress * 100)}%`);
chipTarget.set(progress * travel.get() + metrics.thumb / 2);
};
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = event.target as HTMLElement;
const tick = target.dataset.railTick;
if (tick !== undefined) {
markers[Number(tick)]?.element.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" });
return;
}
if (target.closest("[data-rail-thumb]")) {
event.preventDefault();
zoneRef.current?.setPointerCapture(event.pointerId);
drag.current = { startY: event.clientY, startScroll: window.scrollY };
setDragging(true);
previewing.current = false;
setPreview(null);
chipTarget.set(thumbY.get() + thumbHalf.get());
return;
}
window.scrollTo({ top: progressAt(localY(event)) * metrics.scrollable, behavior: reduced ? "instant" : "smooth" });
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drag.current) return;
drag.current = null;
setDragging(false);
if (zoneRef.current?.hasPointerCapture(event.pointerId)) zoneRef.current.releasePointerCapture(event.pointerId);
};
const onPointerLeave = () => {
setHovering(false);
previewing.current = false;
setPreview(null);
const progress = scrollYProgress.get();
setCurrent(sectionAt(markers, progress));
percent.set(`${Math.round(progress * 100)}%`);
chipTarget.set(thumbY.get() + thumbHalf.get());
};
if (metrics.scrollable <= 1 || metrics.track <= 36) return null;
const engaged = hovering || dragging;
const marker = current >= 0 ? markers[current] : undefined;
return (
<div
ref={zoneRef}
data-vb="scroll-rail"
data-mode="pointer"
aria-hidden="true"
className={cn("fixed right-0 z-(--vb-z-header) w-[22px] touch-none select-none", engaged ? "cursor-pointer" : "cursor-default", className)}
style={{ top: top - 10, bottom: bottom - 10 }}
onPointerEnter={() => setHovering(true)}
onPointerLeave={onPointerLeave}
onPointerMove={onPointerMove}
onPointerDown={onPointerDown}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="absolute top-2.5 right-[9px] bottom-2.5 w-0.5 rounded-full bg-[color-mix(in_oklab,var(--muted-foreground)_24%,transparent)]">
<m.span className="pointer-events-none absolute top-0 left-0 h-full w-0.5 origin-top rounded-full opacity-50" style={{ scaleY: trailScale, background: RAIL_SPECTRUM }} />
{markers.map((item, index) => (
<span
key={`${item.index}-${index}`}
data-rail-tick={index}
className={cn(
"absolute -right-1.5 h-3 w-3.5 -translate-y-1/2 after:absolute after:top-1/2 after:right-0 after:h-px after:w-full after:origin-center after:transition-[transform,background-color] after:duration-200 after:ease-expo-out hover:after:bg-spectrum-3",
index <= reached ? "after:bg-muted-foreground" : "after:bg-[color-mix(in_oklab,var(--muted-foreground)_50%,transparent)]",
engaged ? "after:scale-x-100" : "after:scale-x-50",
)}
style={{ top: item.progress * (metrics.track - metrics.thumb) + metrics.thumb / 2 }}
/>
))}
<m.span
data-rail-thumb=""
className={cn(
"absolute top-0 -left-px w-1 overflow-hidden rounded-full shadow-[0_0_14px_color-mix(in_oklab,var(--spectrum-3)_45%,transparent)] transition-[scale] duration-200 ease-expo-out",
engaged ? "[scale:1.75_1]" : "[scale:1_1]",
)}
style={{ y: thumbY, height: metrics.thumb }}
>
<m.span className="absolute top-0 left-0 w-full" style={{ y: fillY, height: metrics.track, background: RAIL_SPECTRUM }} />
</m.span>
<m.div className={cn(readoutClasses, "top-0 right-[22px]", engaged || awake ? "opacity-100" : "opacity-0")} style={{ y: reduced ? chipTarget : chipSpring }}>
<span className="flex h-3 items-end gap-0.5">
{bars.map((bar, index) => (
<m.span key={BAR_STOPS[index]} className="h-3 w-0.5 origin-bottom rounded-full" style={{ scaleY: bar, background: `var(--spectrum-${BAR_STOPS[index]})` }} />
))}
</span>
{marker ? (
<>
<span className="font-mono text-[0.7rem] text-foreground">{marker.index}</span>
<span className="max-w-[230px] truncate">{marker.label}</span>
</>
) : markers.length > 0 ? (
<span className="max-w-[230px] truncate">{introLabel}</span>
) : null}
<m.span className="min-w-[3.4ch] text-right font-mono text-[0.7rem] text-subtle-foreground">{percent}</m.span>
</m.div>
</div>
</div>
);
}
function TouchRail({ root, sections, sectionOffset, top, bottom, introLabel, className }: RailProps) {
const reduced = useReducedMotion();
const { metrics, markers, travel, trackHeight, thumbHalf } = useRailMetrics(top, bottom, 28, root, sectionOffset, sections);
const [current, setCurrent] = useState(-1);
const [awake, setAwake] = useState(false);
const [dragging, setDragging] = useState(false);
const awakeTimer = useRef(0);
const drag = useRef<{ startY: number; startScroll: number } | null>(null);
const percent = useMotionValue("0%");
const { scrollYProgress } = useScroll();
const thumbY = useTransform<number, number>([scrollYProgress, travel], ([progress = 0, distance = 0]) => progress * distance);
const fillY = useTransform(thumbY, (value) => -value);
const trailScale = useTransform<number, number>([thumbY, travel, trackHeight], ([y = 0, distance = 0, track = 1]) => (y + (track - distance) / 2) / Math.max(1, track));
const chipY = useTransform<number, number>([thumbY, thumbHalf], ([y = 0, half = 0]) => y + half);
const chipSpring = useSpring(chipY, { stiffness: 700, damping: 48, mass: 0.6 });
const sync = useCallback(
(progress: number) => {
setCurrent((previous) => {
const next = sectionAt(markers, progress);
return previous === next ? previous : next;
});
percent.set(`${Math.round(progress * 100)}%`);
},
[markers, percent],
);
useMotionValueEvent(scrollYProgress, "change", (progress) => {
sync(progress);
setAwake(true);
window.clearTimeout(awakeTimer.current);
awakeTimer.current = window.setTimeout(() => setAwake(false), 1200);
});
useEffect(() => {
sync(scrollYProgress.get());
}, [sync, scrollYProgress, metrics]);
useEffect(() => () => window.clearTimeout(awakeTimer.current), []);
const onPointerDown = (event: ReactPointerEvent<HTMLSpanElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { startY: event.clientY, startScroll: window.scrollY };
setDragging(true);
};
const onPointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (!drag.current) return;
const distance = Math.max(1, metrics.track - metrics.thumb);
window.scrollTo({ top: drag.current.startScroll + ((event.clientY - drag.current.startY) * metrics.scrollable) / distance, behavior: "instant" });
};
const endDrag = (event: ReactPointerEvent<HTMLSpanElement>) => {
if (!drag.current) return;
drag.current = null;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
if (metrics.scrollable <= 1 || metrics.track <= 28) return null;
const marker = current >= 0 ? markers[current] : undefined;
const engaged = awake || dragging;
const grip = Math.max(metrics.thumb, 44);
return (
<div
data-vb="scroll-rail"
data-mode="touch"
aria-hidden="true"
className={cn("pointer-events-none fixed right-0 z-(--vb-z-header) w-3.5 transition-opacity duration-300 ease-expo-out", engaged ? "opacity-100" : "opacity-55", className)}
style={{ top, bottom }}
>
<div className="absolute top-0 right-[5px] bottom-0 w-0.5 rounded-full bg-[color-mix(in_oklab,var(--muted-foreground)_22%,transparent)]">
<m.span className="pointer-events-none absolute top-0 left-0 h-full w-0.5 origin-top rounded-full opacity-50" style={{ scaleY: trailScale, background: RAIL_SPECTRUM }} />
<m.span
className={cn("absolute top-0 -left-px w-1 overflow-hidden rounded-full transition-[scale] duration-200 ease-expo-out", dragging ? "[scale:1.75_1]" : "[scale:1_1]")}
style={{ y: thumbY, height: metrics.thumb }}
>
<m.span className="absolute top-0 left-0 w-full" style={{ y: fillY, height: metrics.track, background: RAIL_SPECTRUM }} />
</m.span>
<m.span
data-rail-grip=""
className="pointer-events-auto absolute -right-2 w-[34px] touch-none"
style={{ y: thumbY, top: -(grip - metrics.thumb) / 2, height: grip }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
<m.div className={cn(readoutClasses, "top-0 right-4", engaged && markers.length > 0 ? "opacity-100" : "opacity-0")} style={{ y: reduced ? chipY : chipSpring }}>
{marker ? (
<>
<span className="font-mono text-[0.7rem] text-foreground">{marker.index}</span>
<span className="max-w-[230px] truncate">{marker.label}</span>
</>
) : (
<span className="max-w-[230px] truncate">{introLabel}</span>
)}
<m.span className="min-w-[3.4ch] text-right font-mono text-[0.7rem] text-subtle-foreground">{percent}</m.span>
</m.div>
</div>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| root | string | "main" | Selector for the element whose [data-rail-index] children become ticks. |
| sectionOffset | number | 72 | Where a jump lands a section (match scroll-margin-top). |
| top | number | 96 | Space above the rail on wide screens. |
| bottom | number | 24 | Space below the rail on wide screens. |
| touchTop | number | 86 | Space above the rail on touch screens. |
| touchBottom | number | 96 | Space below the rail on touch screens (clear a chat launcher). |
| introLabel | string | "Intro" | Readout text before the first section. |
| hideNativeScrollbar | boolean | true | Hide the browser scrollbar where the rail replaces it (mouse and trackpad only). |
| className | string | — | Merged with cn(). |
Accessibility and motion
- Keyboard
- Not focusable: the rail is a pointer shortcut; keyboard scrolling stays native.
- Screen readers
- Hidden (aria-hidden); sections keep their own headings.
- Touch
- Touch screens get their own rail whose thumb can be dragged; the rest of it lets taps through.
- Reduced motion
- The rail and readout follow the scroll without springs or bouncing bars; jumps are instant.
Edge cases and limits
- Sections added after load are picked up on the next resize or layout change (the rail observes the body).
- A page with no marked sections still gets the thumb and percentage.
Related

Build Story
FreeA process told by scrolling: steps pin beside an artboard of a website that builds itself, phase by phase.
Scroll · Motion

A menu button whose three spectrum bars idle like a level meter and fold into an X when the menu opens.
Navigation · CSS

A header wordmark whose leading letters bounce like equalizer bars with scroll speed, with a spectrum tint on hover.
Brand and proof · Motion