Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
labelstring"On this page"On ScrollRail, names the nav landmark and sits above the rail. On ScrollRailItem, a shorter name for the rail.
idstringThe anchor on ScrollRailItem, required and unique on the page.
titlestringThe section heading on ScrollRailItem, required.
childrenReactNodeThe 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.