Skip to content
Motion
FreeIn reviewv1.0.0

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-rail

Controls

<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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/scroll-rail
  3. 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 files
components/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

PropTypeDefaultDescription
rootstring"main"Selector for the element whose [data-rail-index] children become ticks.
sectionOffsetnumber72Where a jump lands a section (match scroll-margin-top).
topnumber96Space above the rail on wide screens.
bottomnumber24Space below the rail on wide screens.
touchTopnumber86Space above the rail on touch screens.
touchBottomnumber96Space below the rail on touch screens (clear a chat launcher).
introLabelstring"Intro"Readout text before the first section.
hideNativeScrollbarbooleantrueHide the browser scrollbar where the rail replaces it (mouse and trackpad only).
classNamestring—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.
  • A list of project steps beside a website artboard being built

    A process told by scrolling: steps pin beside an artboard of a website that builds itself, phase by phase.

    Scroll · Motion

  • A square menu button with three spectrum bars

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

    Navigation · CSS

  • A wordmark in a header with letters at different heights

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

    Brand and proof · Motion

↑ ↓ to move↵ to openesc to close