Scroll rail
A sticky rail of numbered section links with a marker that follows your reading position.
Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.
npx shadcn@latest add https://ui.goodtabs.co/r/scroll-rail.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/scroll-rail
A five part article. Scroll to move the marker, or use the rail to jump.
Usage
import { ScrollRail, ScrollRailItem } from "@/components/ui/scroll-rail"
<ScrollRail>
<ScrollRailItem id="setup" title="Set up the project" label="Setup">
<p>One paragraph of the article.</p>
</ScrollRailItem>
</ScrollRail>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | "On this page" | On ScrollRail, names the nav landmark and sits above the rail. On ScrollRailItem, a shorter name for the rail. |
| id | string | The anchor on ScrollRailItem, required and unique on the page. | |
| title | string | The section heading on ScrollRailItem, required. | |
| children | ReactNode | The ScrollRailItem sections, or a section's prose. |
Source
scroll-rail/scroll-rail.tsx
"use client";
import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { cn } from "@/lib/utils";
gsap.registerPlugin(useGSAP, ScrollTrigger);
/** Where reading happens, as a fraction of the viewport. */
const READING_LINE = 0.4;
const ItemIndex = React.createContext(0);
const pad = (index: number) => String(index + 1).padStart(2, "0");
type ScrollRailProps = React.ComponentProps<"div"> & {
/** Names the section links, for the nav landmark and the label above the rail. */
label?: string;
};
/**
* A long article with a sticky rail of numbered section links. The rail is built from the
* ScrollRailItem children, so the article is written once. Every link is a real anchor, so the rail
* still works with no script; the marker and aria-current arrive with it.
*/
function ScrollRail({ label = "On this page", className, children, ...props }: ScrollRailProps) {
const root = React.useRef<HTMLDivElement>(null);
const list = React.useRef<HTMLOListElement>(null);
const [active, setActive] = React.useState(-1);
const entries = React.Children.toArray(children).flatMap((child) => {
if (!React.isValidElement<ScrollRailItemProps>(child)) return [];
const { id, title, label: short } = child.props;
return [{ id, label: short ?? title }];
});
useGSAP(
() => {
const sections = gsap.utils.toArray<HTMLElement>("[data-slot=scroll-rail-item]");
const links = gsap.utils.toArray<HTMLElement>("[data-slot=scroll-rail-link]");
if (!sections.length || !list.current) return;
const strip = list.current;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const moveMarker = gsap.quickSetter("[data-slot=scroll-rail-marker]", "y", "px");
const fillStrip = gsap.quickSetter("[data-slot=scroll-rail-bar]", "scaleX");
let tops: number[] = [];
let rows: number[] = [];
let bottom = 0;
let current = -1;
const measure = () => {
tops = sections.map((section) => section.getBoundingClientRect().top + window.scrollY);
bottom = sections[sections.length - 1].getBoundingClientRect().bottom + window.scrollY;
rows = links.map((link) => link.offsetTop);
};
const update = () => {
const view = window.innerHeight;
// The line slides to the bottom of the viewport over the last stretch of the page, so the
// final section still lights up however short it is.
const room = (1 - READING_LINE) * view;
const left = ScrollTrigger.maxScroll(window) - window.scrollY;
const line = window.scrollY + view - room * Math.min(1, left / room);
let index = 0;
tops.forEach((top, i) => {
if (top <= line) index = i;
});
const next = tops[index + 1] ?? bottom;
const through = gsap.utils.clamp(0, 1, (line - tops[index]) / (next - tops[index]));
if (reduced.matches) {
moveMarker(rows[index]);
} else {
const nextRow = rows[index + 1] ?? rows[index];
moveMarker(rows[index] + (nextRow - rows[index]) * through);
fillStrip(gsap.utils.clamp(0, 1, (line - tops[0]) / (bottom - tops[0])));
}
if (index !== current) {
current = index;
setActive(index);
// On phones the rail is a strip that scrolls sideways: keep the active link in it.
if (strip.scrollWidth > strip.clientWidth) {
const link = links[index];
strip.scrollTo({
left: link.offsetLeft - (strip.clientWidth - link.offsetWidth) / 2,
behavior: reduced.matches ? "auto" : "smooth",
});
}
}
};
const settle = () => {
measure();
update();
};
ScrollTrigger.create({
trigger: root.current,
start: "top bottom",
end: "bottom top",
onUpdate: update,
onRefresh: settle,
});
settle();
const observer = new ResizeObserver(settle);
observer.observe(root.current as HTMLElement);
return () => observer.disconnect();
},
{ scope: root, dependencies: [entries.length] },
);
return (
<div
ref={root}
data-slot="scroll-rail"
className={cn(
"mx-auto grid w-full max-w-5xl grid-cols-1 gap-x-16 md:grid-cols-[13rem_minmax(0,1fr)] md:px-8",
className,
)}
{...props}
>
<nav
aria-label={label}
className="sticky top-0 z-10 border-b bg-background/90 backdrop-blur-sm md:top-24 md:self-start md:border-b-0 md:bg-transparent md:backdrop-blur-none"
>
<p className="m-0 mb-3 hidden font-mono text-xs tracking-wide text-muted-foreground uppercase md:block">
{label}
</p>
<ol
ref={list}
className="relative m-0 flex h-11 [scrollbar-width:none] list-none gap-5 overflow-x-auto px-6 md:block md:h-auto md:overflow-visible md:border-l md:px-0"
>
{entries.map((entry, index) => (
<li key={entry.id} className="shrink-0">
<a
href={`#${entry.id}`}
data-slot="scroll-rail-link"
aria-current={active === index ? "location" : undefined}
className="flex h-11 items-center gap-2 rounded-sm text-sm whitespace-nowrap text-muted-foreground transition-colors duration-100 ease-out hover:text-foreground focus-visible:-outline-offset-2 aria-[current]:text-foreground md:h-9 md:gap-3 md:pl-4 md:whitespace-normal"
>
<span className="font-mono text-xs">{pad(index)}</span>
<span className="md:truncate">{entry.label}</span>
</a>
</li>
))}
<li
aria-hidden="true"
data-slot="scroll-rail-marker"
className={cn(
"pointer-events-none absolute top-0 -left-px hidden h-9 w-px bg-foreground md:block",
active < 0 && "md:hidden",
)}
/>
</ol>
<div
aria-hidden="true"
data-slot="scroll-rail-bar"
className="absolute inset-x-0 -bottom-px h-px origin-left scale-x-0 bg-foreground motion-reduce:hidden md:hidden"
/>
</nav>
<div className="min-w-0 px-6 md:px-0">
{React.Children.toArray(children).map((child, index) => (
<ItemIndex.Provider key={index} value={index}>
{child}
</ItemIndex.Provider>
))}
</div>
</div>
);
}
type ScrollRailItemProps = Omit<React.ComponentProps<"section">, "title"> & {
/** The anchor the rail links to. Required, and unique on the page. */
id: string;
/** The section heading. */
title: string;
/** A shorter name for the rail and the phone strip. Falls back to the title. */
label?: string;
};
/** One section of the article. Must be a direct child of ScrollRail. */
function ScrollRailItem({ id, title, label, className, children, ...props }: ScrollRailItemProps) {
const index = React.useContext(ItemIndex);
return (
<section
id={id}
aria-labelledby={`${id}-heading`}
data-slot="scroll-rail-item"
data-label={label}
className={cn("scroll-mt-20 pt-14 pb-2 md:scroll-mt-24 md:pt-0 md:pb-24", className)}
{...props}
>
<p className="m-0 font-mono text-xs tracking-wide text-muted-foreground uppercase">{pad(index)}</p>
<h2 id={`${id}-heading`} className="mt-3 mb-0 text-3xl leading-none font-medium tracking-tight md:text-5xl">
{title}
</h2>
<div className="mt-6 flex max-w-xl flex-col gap-4 text-base text-muted-foreground">{children}</div>
</section>
);
}
export { ScrollRail, ScrollRailItem };
For AI agents
Hand your agent this page, the markdown at /components/scroll-rail/prompt.md, or the Copy AI prompt button above.
When to use
- A long article, guide or changelog read from top to bottom
- A docs page where people jump between sections
- A case study with four to eight named parts
When not to use
- On a short page, because a rail with two links explains nothing
- For site navigation: this only links inside the page
- For more than about ten sections, where the phone strip gets long
Agent notes
- GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
- ScrollRailItem must be a direct child of ScrollRail, because the rail is read from its children
- Links are real anchors and the rail is sticky in CSS, so it works with no script. aria-current="location" and the marker need the script
- Below 768px the rail is a sticky top strip with a progress hairline, and the active link scrolls into it
- Under reduced motion the marker still tracks the active section but jumps instead of scrubbing
- It reads window scroll, so it must not sit inside an overflow container
The maths
- The reading line sits at 40 percent of the viewport and slides to 100 percent over the last 60 percent of a screen of scroll, so the last section always lights up.
- Marker y is rows[i] + t × (rows[i + 1] - rows[i]), where t is how far the line is through section i, so long and short sections both reach the next row exactly on time.
- The rail takes 13rem and the gap 4rem from a 60rem column, leaving 43rem for headings. Prose stops at max-w-xl, 36rem, about 75 characters at 16px.