# Dot Rail: LED Dot Tab Indicator for React

> A strip of LED dots that shows where you are. A run of lit dots jumps under the tab or link you point at, counts up under an autoplaying tab like a timer, sits at how far you've read, or loops while something loads. The dots it leaves glow for a moment and fade, like an LED sign, so a quick move draws a short comet tail.

LED Dot Tab Indicator for React: LED dots track the active tab or how far you've read. Built with Tailwind CSS and Motion; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/dot-rail
- Tags: Navigation, Progress, Canvas
- npm dependencies: motion, clsx, tailwind-merge
- Inspired by: [Citylines on vibrant.design](https://vibrant.design/p/ezibddx-citylines-transit-landing-page)

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/dot-rail.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/dot-rail
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/dot-rail.json
```

## Usage

The main demo shows typical usage.

### Feature Tabs

An AI note-taker's four features as tabs that play by themselves. The dots under the current tab count up like a timer, the rail jumps to any tab you point at, and picking one stops the autoplay.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/dot-rail-demo
```

```tsx title="components/dot-rail-demo.tsx"
"use client";

import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";
import { DotRail } from "@/components/ui/dot-rail";

const DWELL = 5000; // ms each feature stays up while it plays by itself
const TICK = 80; // ms between steps of the timer under the current tab

type Feature = { id: string; label: string; title: string; body: string; view: React.ReactNode };

// Minutes, an AI note-taker: four things it does after a call, one per tab.
const FEATURES: Feature[] = [
  {
    id: "record",
    label: "Record",
    title: "It joins the call so nobody has to type",
    body: "Minutes sits in on Zoom, Meet and Teams, and tells everyone it's there.",
    view: <RecordView />,
  },
  {
    id: "summarize",
    label: "Summarize",
    title: "The decisions, not the transcript",
    body: "Thirty minutes of talk comes back as five lines, with links to the moments behind them.",
    view: <SummaryView />,
  },
  {
    id: "assign",
    label: "Assign",
    title: "Every promise gets an owner",
    body: "“I'll send that over” becomes a task with a name and a date on it.",
    view: <TasksView />,
  },
  {
    id: "follow-up",
    label: "Follow up",
    title: "The recap is written before you're back at your desk",
    body: "Review it, change a word, and send it to everyone who was there.",
    view: <RecapView />,
  },
];

export function DotRailDemo() {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const tabsRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(rootRef, { amount: 0.4 });
  const reduced = useReducedMotion();
  const [clock, setClock] = React.useState({ active: 0, elapsed: 0 });
  const [auto, setAuto] = React.useState(true);
  const [paused, setPaused] = React.useState(false);

  const playing = auto && !paused && inView && !reduced;
  React.useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => {
      setClock(({ active, elapsed }) =>
        elapsed + TICK >= DWELL
          ? { active: (active + 1) % FEATURES.length, elapsed: 0 }
          : { active, elapsed: elapsed + TICK },
      );
    }, TICK);
    return () => window.clearInterval(id);
  }, [playing]);

  const pick = (index: number) => {
    setAuto(false);
    setClock({ active: index, elapsed: 0 });
  };

  const onKey = (e: React.KeyboardEvent<HTMLButtonElement>, index: number) => {
    const n = FEATURES.length;
    const to =
      e.key === "ArrowRight" ? (index + 1) % n : e.key === "ArrowLeft" ? (index - 1 + n) % n : e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1;
    if (to < 0) return;
    e.preventDefault();
    pick(to);
    tabsRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[to]?.focus();
  };

  const { active, elapsed } = clock;

  return (
    <div
      ref={rootRef}
      onPointerEnter={(e) => e.pointerType === "mouse" && setPaused(true)}
      onPointerLeave={(e) => e.pointerType === "mouse" && setPaused(false)}
      className="@container w-full max-w-4xl rounded-2xl border border-zinc-950/10 bg-[#fafaf9] p-5 text-zinc-950 [--dot-rail-accent:#ff5a1f] @[40rem]:p-8 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100 dark:[--dot-rail-accent:#ff6a2b]"
    >
      <h2 className="max-w-lg font-serif text-3xl leading-[1.05] tracking-[-0.02em] text-balance @[40rem]:text-[2.6rem]">
        Meetings that <em className="text-[#2747ff] dark:text-[#8292ff]">finish</em> themselves
      </h2>

      <div ref={tabsRef} role="tablist" aria-label="What Minutes does" className="mt-8 grid grid-cols-4">
        {FEATURES.map((feature, index) => (
          <button
            key={feature.id}
            id={`minutes-tab-${feature.id}`}
            type="button"
            role="tab"
            aria-selected={index === active}
            aria-controls={`minutes-panel-${feature.id}`}
            tabIndex={index === active ? 0 : -1}
            onClick={() => pick(index)}
            onKeyDown={(e) => onKey(e, index)}
            className="rounded-md px-1 pb-2.5 text-left text-[13px] font-medium text-zinc-400 transition-colors duration-150 outline-none hover:text-zinc-950 focus-visible:text-zinc-950 focus-visible:underline focus-visible:decoration-[#ff5a1f] focus-visible:underline-offset-4 aria-selected:text-zinc-950 @[40rem]:text-sm dark:text-zinc-500 dark:hover:text-zinc-100 dark:focus-visible:text-zinc-100 dark:aria-selected:text-zinc-100"
          >
            {feature.label}
          </button>
        ))}
      </div>
      <DotRail trackRef={tabsRef} fill={auto && !reduced ? elapsed / DWELL : 1} />

      <div className="mt-6 grid">
        {FEATURES.map((feature, index) => (
          <section
            key={feature.id}
            id={`minutes-panel-${feature.id}`}
            role="tabpanel"
            aria-labelledby={`minutes-tab-${feature.id}`}
            inert={index !== active}
            className={cn(
              "col-start-1 row-start-1 grid gap-6 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0 @[40rem]:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] @[40rem]:items-center @[40rem]:gap-10",
              index === active ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1.5 opacity-0",
            )}
          >
            <div>
              <h3 className="text-lg leading-snug font-semibold tracking-[-0.01em] text-balance">{feature.title}</h3>
              <p className="mt-2 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">{feature.body}</p>
            </div>
            {feature.view}
          </section>
        ))}
      </div>
    </div>
  );
}

/* ── The product, one view per tab ───────────────────────── */

function Card({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <div
      className={cn(
        "rounded-xl bg-white p-4 shadow-sm ring-1 ring-zinc-950/[0.06] dark:bg-zinc-900/70 dark:shadow-none dark:ring-white/[0.08]",
        className,
      )}
    >
      {children}
    </div>
  );
}

const WAVE = [6, 14, 22, 12, 30, 18, 26, 9, 16, 34, 24, 12, 20, 28, 10, 18, 32, 14, 8, 22, 26, 12, 18, 30, 16, 10, 24, 20, 12, 6];

function RecordView() {
  return (
    <Card>
      <div className="flex items-center justify-between text-xs">
        <span className="flex items-center gap-1.5 font-medium">
          <span className="size-1.5 rounded-full bg-[#ff5a1f]" />
          Recording · Q4 planning
        </span>
        <span className="font-mono text-zinc-500 tabular-nums">24:13</span>
      </div>
      <div className="mt-4 flex h-9 items-center gap-[3px]" aria-hidden>
        {WAVE.map((h, i) => (
          <span key={i} className="w-full rounded-full bg-zinc-950/80 dark:bg-zinc-100/80" style={{ height: h }} />
        ))}
      </div>
      <div className="mt-4 flex items-center gap-2 text-xs text-zinc-500">
        <div className="flex -space-x-1">
          {["A", "T", "M", "J"].map((who) => (
            <span
              key={who}
              className="grid size-6 place-items-center rounded-full bg-zinc-200 text-[10px] font-semibold text-zinc-700 ring-2 ring-white dark:bg-zinc-700 dark:text-zinc-200 dark:ring-zinc-900"
            >
              {who}
            </span>
          ))}
        </div>
        6 people, 2 rooms
      </div>
    </Card>
  );
}

function SummaryView() {
  const lines = [
    "Ship the export redesign on Nov 18, not Dec 2.",
    "Pricing stays as is until the January review.",
    "Support hires two more people in Lisbon.",
  ];
  return (
    <Card>
      <p className="text-xs font-medium text-zinc-500">Summary · 3 decisions</p>
      <ul className="mt-3 space-y-2.5 text-[13px] leading-snug">
        {lines.map((line, i) => (
          <li key={line} className="flex gap-2.5">
            <span className="mt-[7px] size-1 shrink-0 rounded-full bg-current" />
            <span>
              {line}{" "}
              <span className="font-mono text-[11px] text-[#2747ff] tabular-nums dark:text-[#8292ff]">
                {["04:12", "11:40", "19:05"][i]}
              </span>
            </span>
          </li>
        ))}
      </ul>
    </Card>
  );
}

function TasksView() {
  const tasks = [
    { what: "Send the export spec to design", who: "Ana", due: "Thu" },
    { what: "Draft the January pricing memo", who: "Theo", due: "Nov 29" },
    { what: "Open two Lisbon support roles", who: "Mika", due: "Mon" },
  ];
  return (
    <Card className="p-0">
      <ul className="divide-y divide-zinc-950/[0.06] text-[13px] dark:divide-white/[0.06]">
        {tasks.map((task) => (
          <li key={task.what} className="flex items-center gap-3 px-4 py-3">
            <span className="size-3.5 shrink-0 rounded-[4px] ring-1 ring-zinc-950/25 dark:ring-white/30" />
            <span className="min-w-0 flex-1 truncate">{task.what}</span>
            <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium dark:bg-white/10">{task.who}</span>
            <span className="w-12 text-right font-mono text-[11px] text-zinc-500 tabular-nums">{task.due}</span>
          </li>
        ))}
      </ul>
    </Card>
  );
}

function RecapView() {
  return (
    <Card>
      <div className="space-y-1 text-xs text-zinc-500">
        <p>
          To <span className="text-zinc-950 dark:text-zinc-100">6 people from Q4 planning</span>
        </p>
        <p>
          Subject <span className="text-zinc-950 dark:text-zinc-100">Recap: export date, pricing, Lisbon</span>
        </p>
      </div>
      <p className="mt-3 border-t border-zinc-950/[0.06] pt-3 text-[13px] leading-relaxed dark:border-white/[0.06]">
        Thanks all. We moved the export redesign up to Nov 18 and kept pricing as is until January. Three owners below.
      </p>
      <div className="mt-4 flex items-center justify-between">
        <span className="text-[11px] text-zinc-500">Drafted 2 min after the call</span>
        <span className="rounded-full bg-zinc-950 px-3 py-1.5 text-xs font-medium text-white dark:bg-zinc-100 dark:text-zinc-950">
          Send recap
        </span>
      </div>
    </Card>
  );
}
```

## Examples

Finished sections built with Dot Rail. Each one installs whole, with the component included.

### Site Header

A print studio's header. The rail under it runs to whichever link you point at and parks under the current page when you leave.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/dot-rail-header-demo
```

```tsx title="components/dot-rail-header-demo.tsx"
"use client";

import * as React from "react";

import { DotRail } from "@/components/ui/dot-rail";

const links = [
  { label: "Prints", href: "#prints" },
  { label: "Studio", href: "#studio" },
  { label: "Workshops", href: "#workshops" },
  { label: "Journal", href: "#journal" },
];

function Mark() {
  return (
    <svg viewBox="0 0 20 20" aria-hidden className="size-5">
      <circle cx="10" cy="10" r="8.25" className="fill-none stroke-current" strokeWidth="1.5" />
      <circle cx="10" cy="10" r="3.25" className="fill-[var(--dot-rail-accent)]" />
    </svg>
  );
}

export function DotRailHeaderDemo() {
  const navRef = React.useRef<HTMLElement>(null);
  const [current, setCurrent] = React.useState("#studio");

  return (
    <div className="@container w-full max-w-4xl overflow-hidden rounded-2xl border border-[#0b0b0d]/10 bg-[#fafaf9] text-[#0b0b0d] [--dot-rail-accent:#ff5a1f] dark:border-white/10 dark:bg-[#07080c] dark:text-[#f4f4f6] dark:[--dot-rail-accent:#ff6a2b]">
      <header className="px-5 pt-4 @[44rem]:px-8">
        <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
          <a
            href="#"
            onClick={(e) => e.preventDefault()}
            className="flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current"
          >
            <Mark />
            Pressroom
          </a>
          <nav
            ref={navRef}
            aria-label="Main"
            className="order-last -mx-3 flex w-[calc(100%+1.5rem)] justify-between @[28rem]:mx-0 @[28rem]:w-full @[28rem]:justify-center @[28rem]:gap-2 @[44rem]:order-none @[44rem]:w-auto"
          >
            {links.map((link) => (
              <a
                key={link.href}
                href={link.href}
                aria-current={current === link.href ? "page" : undefined}
                onClick={(e) => {
                  e.preventDefault();
                  setCurrent(link.href);
                }}
                className="rounded-full px-3 py-2 text-[13px] font-medium text-[#0b0b0d]/60 transition-colors duration-150 hover:text-[#0b0b0d] focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-current aria-[current=page]:text-[#0b0b0d] dark:text-[#f4f4f6]/60 dark:hover:text-[#f4f4f6] dark:aria-[current=page]:text-[#f4f4f6]"
              >
                {link.label}
              </a>
            ))}
          </nav>
          <a
            href="#book"
            onClick={(e) => e.preventDefault()}
            className="rounded-full bg-[#0b0b0d] px-4 py-2 text-[13px] font-medium text-[#fafaf9] transition-transform duration-150 active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current dark:bg-[#f4f4f6] dark:text-[#07080c]"
          >
            Book a print run
          </a>
        </div>
        <DotRail trackRef={navRef} className="mt-2" />
      </header>

      <section className="px-5 pt-10 pb-12 text-center @[44rem]:px-8 @[44rem]:pt-14 @[44rem]:pb-16">
        <h2 className="font-serif text-4xl tracking-tight text-balance @[44rem]:text-[3.25rem] @[44rem]:leading-[1.05]">
          Small runs, <em className="text-[#2747ff] dark:text-[#8292ff]">loud</em> colors
        </h2>
        <p className="mx-auto mt-4 max-w-md text-[15px] leading-relaxed text-[#0b0b0d]/70 dark:text-[#f4f4f6]/70">
          Zines, posters and lookbooks printed a hundred at a time on two riso drums, in a back room off Calder Street.
        </p>
        <p className="mt-6 font-mono text-[11px] tracking-[0.18em] text-[#0b0b0d]/60 uppercase dark:text-[#f4f4f6]/60">
          Riso · 2 drums · 11 inks
        </p>
      </section>
    </div>
  );
}
```

### Reading Progress

A long read whose rail sits at how far you've scrolled. Scroll fast and the tail stretches out behind it.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/dot-rail-reading-demo
```

```tsx title="components/dot-rail-reading-demo.tsx"
"use client";

import * as React from "react";

import { DotRail } from "@/components/ui/dot-rail";

const MINUTES = 6; // reading time for the whole piece

const paragraphs = [
  "Ride the N4 out of Varn after midnight and, somewhere past the fish market, the bus does something no timetable explains. It slows, swings wide around a corner nobody needs to swing wide around, and runs the next half mile down the middle of the road, as if it were keeping to a line painted there.",
  "It is. The paint has been gone for fifty years, but the line hasn't. The N4 follows the old number 4 tram, rail for rail, because when the trams stopped in 1974 the city gave their routes to the buses and never took them back. The drivers learned the corners from the tram men, and the next drivers learned them from those.",
  "You can read the whole network this way. The night lines bend where the depots were. They stop at shelters that were tram halts, under iron roofs too long for a single bus. The 11 still waits two minutes at Harbour Gate, which made sense when the tram had to change its pole there and makes none now.",
];

const paragraphsAfter = [
  "Planners have tried to straighten them. In 2009 a consultancy redrew the night map from scratch, with routes chosen by where people actually live now. It was faster on paper by eleven minutes a trip. Ridership fell by a fifth in the first winter, and most of the old lines were back by spring.",
  "The drivers had a theory about that, and it holds up. A night bus isn't only carrying people home; it's a lit room moving through a dark city on a path everyone already knows. People time their walk to the stop by the old tram bell at St. Anne's. Bakers set their ovens by the first 4 going past. A faster route that nobody can picture is a worse route at two in the morning.",
  "So the city stopped fighting it. The new shelters going up along the 4 are built to the old halt length, with room for a tram that isn't coming, and the depot at Calder Street, the last one standing, is being turned into a bus garage that keeps its rails in the floor.",
  "Ride it once with this in mind and you won't unsee it: the long, unhurried bend at the market, the pause at Harbour Gate, the stretch down the middle of the road. The bus isn't lost. It's remembering.",
];

export function DotRailReadingDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const [left, setLeft] = React.useState(MINUTES);

  React.useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    let raf = 0;
    const read = () => {
      raf = 0;
      const max = el.scrollHeight - el.clientHeight;
      const p = max > 0 ? el.scrollTop / max : 0;
      setLeft(Math.ceil((1 - p) * MINUTES - 0.05));
    };
    const onScroll = () => {
      if (!raf) raf = requestAnimationFrame(read);
    };
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      el.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, []);

  return (
    <div className="relative w-full overflow-hidden rounded-xl border border-[#0b0b0d]/10 bg-[#fafaf9] text-[#0b0b0d] [--dot-rail-accent:#ff5a1f] dark:border-white/10 dark:bg-[#07080c] dark:text-[#f4f4f6] dark:[--dot-rail-accent:#ff6a2b]">
      <div ref={scrollRef} className="@container relative h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <div className="sticky top-0 z-10 bg-[#fafaf9] px-5 pt-3.5 dark:bg-[#07080c] @[40rem]:px-8">
          <div className="flex items-center justify-between gap-4 font-mono text-[11px] tracking-[0.16em] uppercase">
            <span className="truncate">
              Tram <em className="font-serif text-[13px] tracking-normal normal-case">&amp;</em> Tide · No. 14
            </span>
            <span className="shrink-0 text-[#0b0b0d]/60 tabular-nums dark:text-[#f4f4f6]/60">
              {left <= 0 ? "Finished" : `${left} min left`}
            </span>
          </div>
          <DotRail follow="scroll" containerRef={scrollRef} aria-label="Reading progress" className="mt-2.5 mb-px" />
        </div>

        <article className="mx-auto max-w-[36rem] px-5 pt-10 pb-24 @[40rem]:px-8 @[40rem]:pt-14">
          <h2 className="font-serif text-4xl leading-[1.05] tracking-tight text-balance @[40rem]:text-5xl">
            The night bus <em className="text-[#2747ff] dark:text-[#8292ff]">remembers</em> the tram
          </h2>
          <p className="mt-5 text-[15px] text-[#0b0b0d]/60 dark:text-[#f4f4f6]/60">
            By Ines Okafor · Photographs by the author
          </p>

          <div className="mt-10 space-y-5 text-[16px] leading-[1.7] text-[#0b0b0d]/85 dark:text-[#f4f4f6]/85">
            {paragraphs.map((p) => (
              <p key={p.slice(0, 24)}>{p}</p>
            ))}
            <blockquote className="my-10 border-l border-current/20 pl-5 font-serif text-2xl leading-snug text-[#0b0b0d] italic dark:text-[#f4f4f6]">
              “A faster route that nobody can picture is a worse route at two in the morning.”
            </blockquote>
            <h3 className="pt-4 font-serif text-2xl tracking-tight text-[#0b0b0d] dark:text-[#f4f4f6]">Eleven minutes faster</h3>
            {paragraphsAfter.map((p) => (
              <p key={p.slice(0, 24)}>{p}</p>
            ))}
          </div>

          <p className="mt-12 font-mono text-[11px] tracking-[0.18em] text-[#0b0b0d]/50 uppercase dark:text-[#f4f4f6]/50">
            End · Filed from the 4, 01:40
          </p>
        </article>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number \| [number, number]` |  | Where the run sits: a number from 0 (the left end) to 1 (the right end), or a `[from, to]` range to light, such as `[0, 1]` for the whole rail. `follow` and a hovered `trackRef` item take precedence. |
| `follow` | `"scroll" \| "loop"` |  | Let the run place itself: `"scroll"` sits it at the scroll progress of the page (or `containerRef`), `"loop"` sends it round the rail on its own, like a loader. With no `follow`, `value` or `trackRef`, it loops. |
| `containerRef` | `React.RefObject<HTMLElement \| null>` |  | The scrolling element whose progress `follow="scroll"` reads. Defaults to the page. |
| `trackRef` | `React.RefObject<HTMLElement \| null>` |  | A nav, tab list or menu to follow: the run jumps under whichever of its links or buttons is hovered or focused, as wide as it is, and goes back to the one marked `aria-current` (or `data-active`, or `aria-selected`) when the pointer leaves. |
| `fill` | `number` | `1` | With `trackRef`, how much of the current item is lit, from its left edge, from 0 to 1. Drive it from a timer and the dots under an autoplaying tab count up one by one. |
| `rows` | `number` | `3` | Rows of dots in the band. |
| `pitch` | `number` | `6` | Distance between dot centers, in px. The rail is `rows × pitch` tall. |
| `length` | `number` | `6` | How many dots wide the run is when it's a position (a number `value`, scroll or loop). |
| `trail` | `number` | `260` | How long, in ms, a dot keeps glowing after the run leaves it. `0` turns the tail off. |
| `stiffness` | `number` | `900` | Spring stiffness of the end that leads the run when it moves, critically damped so it never overshoots. Higher is snappier. |
| `followStiffness` | `number` | `420` | Spring stiffness of the end that catches up behind it. Lower than `stiffness` and the run stretches toward where it's going and gathers up behind itself. |
| `className` | `string` |  | Extra classes for the rail. Its text color draws the dots, at 30%; set the lit run's color with `--dot-rail-accent` (default `#ff5a1f`), e.g. `[--dot-rail-accent:#2563eb]`. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/dot-rail.tsx`, keep these, which make it feel and behave right:

- One canvas and one rAF loop draw the rail. A frame redraws only the columns whose brightness step changed, and the loop stops once nothing moves or fades, off screen and on a hidden tab.
- A lit dot's halo stays inside its own cell, so redrawing one column never clips a neighbor.
- Each end of the run is its own critically damped spring: the end that grows the run uses `stiffness`, the end that shrinks it `followStiffness`, so the run stretches toward its target.
- A dot is on or off by whether its column is inside the run, never half lit by position; only its fade after the run leaves is gradual, an exponential that's out by `trail`.
- A first target and any relayout land without travel, already lit. Under reduced motion every target does, with no tail, and the loop parks mid-rail and breathes.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/dot-rail.tsx"
"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";

import { cn } from "@/lib/utils";

/**
 * Keep:
 * - One canvas and one rAF loop draw the rail. A frame redraws only the columns whose brightness
 *   step changed, and the loop stops once nothing moves or fades, off screen and on a hidden tab.
 * - A lit dot's halo stays inside its own cell, so redrawing one column never clips a neighbor.
 * - Each end of the run is its own critically damped spring: the end that grows the run uses
 *   `stiffness`, the end that shrinks it `followStiffness`, so the run stretches toward its target.
 * - A dot is on or off by whether its column is inside the run, never half lit by position; only
 *   its fade after the run leaves is gradual, an exponential that's out by `trail`.
 * - A first target and any relayout land without travel, already lit. Under reduced motion every
 *   target does, with no tail, and the loop parks mid-rail and breathes.
 */
const TIMING = {
  /** A dot switching on, in seconds. */
  rise: 0.03,
  /** How long a dot keeps glowing after the run leaves it, in ms (the `trail` prop). */
  trail: 260,
  /**
   * Spring stiffness of the end that grows the run (critically damped; the `stiffness` prop), and
   * of the end that shrinks it (the `followStiffness` prop).
   */
  stiffness: 900,
  followStiffness: 420,
  /** The run's travel under `follow="loop"`, in dots a second. */
  loop: 42,
  /** One breath of the loop under reduced motion, in seconds. */
  breathe: 2.4,
  /** The springs' integration step, in seconds. */
  substep: 0.004,
  /** The longest step one frame advances the springs and fades by, in seconds (after a stall). */
  maxFrame: 0.05,
} as const;

/* ─────────────────────────────────────────────────────────
 * DOT RAIL STORYBOARD
 *
 *  rest     a band of dots three rows deep, at 30% of the text
 *           color: the dotted strip under a navbar. A run of
 *           them is lit in the accent, like a few lamps on an
 *           LED sign, each with a soft halo inside its own cell
 *  light    a dot the run reaches switches on in 30ms. A dot it
 *           leaves doesn't go dark at once: it fades on an
 *           exponential curve and is out by `trail` (260ms), so
 *           a fast move leaves a short comet tail and a slow one
 *           hardly any. A dot is on or off, never half lit by
 *           position; only the fade is gradual
 *  move     each end of the run chases its new place on a
 *           critically damped spring. The end that grows the run
 *           leads (stiffness 900, in about 0.2s), the end that
 *           shrinks it follows (420, about 0.3s), so the run
 *           stretches toward where it's going and gathers up
 *           behind itself
 *  track    with `trackRef`, the run jumps under whichever link
 *           or button in it is hovered or keyboard-focused, as
 *           wide as that item, and goes back to the one marked
 *           aria-current (or data-active) when the pointer
 *           leaves. Mouse and keyboard only; a tap just changes
 *           which one is current
 *  scroll   `follow="scroll"`: the run sits at the scroll
 *           progress of the page, or of `containerRef`, through
 *           the same springs, so its tail grows with scroll speed
 *  loop     `follow="loop"`, and the default with nothing else
 *           to place it: the run travels left to right at 42 dots
 *           a second and wraps round, a loader or an ambient
 *           strip. A target that takes over keeps its speed
 *  timer    `fill` lights only part of the current item, from
 *           its left edge: drive it from a timer and the dots
 *           under an autoplaying tab switch on one by one. When
 *           the next tab becomes current at 0, the run shrinks
 *           over to it, its tail fading on the last one, and
 *           starts counting again. Hover still shows the whole
 *           item
 *  fill     `value={[0, 1]}` lights the whole rail. Both ends
 *           lead, so it floods out from wherever the run was
 *  sleep    the canvas only redraws the columns whose light
 *           changed, and stops once nothing moves or fades, off
 *           screen and on a hidden tab
 *  reduced  no tail and no travel: the run is drawn straight at
 *           its target. The loop parks mid-rail and breathes,
 *           55% to full over 2.4s
 * ───────────────────────────────────────────────────────── */

const RAIL = {
  base: 0.3, // opacity of an unlit dot, as a share of the text color's
  radius: 0.27, // dot radius, as a share of `pitch`
  halo: { radius: 0.5, alpha: 0.3 }, // a lit dot's glow: radius as a share of `pitch` (it stays inside its cell), and its opacity at full light
  floor: 0.03, // brightness under which a fading dot is out
  levels: 24, // brightness steps a dot can be drawn at
  rest: { distance: 0.01, speed: 0.05 }, // dots, dots/s: an end nearer and slower than this has landed
  breathe: { low: 0.55 }, // brightness the loop breathes down to under reduced motion
  accent: "#ff5a1f", // without --dot-rail-accent
  maxDpr: 2,
} as const;

const ITEM = 'a[href], button, [role="tab"], [data-dot-rail-item]';
const ACTIVE =
  '[aria-current]:not([aria-current="false"]), [data-active]:not([data-active="false"]), [aria-selected="true"]';

type Rgba = [number, number, number, number];
type Follow = "scroll" | "loop";

type Options = {
  value: number | [number, number] | undefined;
  fill: number;
  follow: Follow | undefined;
  tracking: boolean;
  rows: number;
  pitch: number;
  length: number;
  trail: number;
  stiffness: number;
  followStiffness: number;
  reduced: boolean;
};

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);

/** Any CSS color (hex, rgb, oklch, a var() already resolved) as RGBA bytes, read off a 1×1 canvas. */
function makeColorReader() {
  let ctx: CanvasRenderingContext2D | null = null;
  return (color: string): Rgba => {
    if (!ctx) {
      const c = document.createElement("canvas");
      c.width = c.height = 1;
      ctx = c.getContext("2d", { willReadFrequently: true });
    }
    if (!ctx) return [0, 0, 0, 1];
    ctx.clearRect(0, 0, 1, 1);
    ctx.fillStyle = "#000";
    ctx.fillStyle = color;
    ctx.fillRect(0, 0, 1, 1);
    const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
    return [r, g, b, a / 255];
  };
}

type Edge = { x: number; v: number; t: number };

/** One end of the run, on a critically damped spring of stiffness `k`. Returns whether it's still moving. */
function stepEdge(edge: Edge, dt: number, k: number) {
  if (Math.abs(edge.x - edge.t) < RAIL.rest.distance && Math.abs(edge.v) < RAIL.rest.speed) {
    edge.x = edge.t;
    edge.v = 0;
    return false;
  }
  const c = 2 * Math.sqrt(k);
  const n = Math.max(1, Math.ceil(dt / TIMING.substep));
  const h = dt / n;
  let { x, v } = edge;
  for (let i = 0; i < n; i++) {
    v += (-k * (x - edge.t) - c * v) * h;
    x += v * h;
  }
  edge.x = x;
  edge.v = v;
  return true;
}

function createRail() {
  let root: HTMLElement | null = null;
  let canvas: HTMLCanvasElement | null = null;
  let ctx: CanvasRenderingContext2D | null = null;
  const readColor = makeColorReader();

  let options: Options = {
    value: undefined,
    fill: 1,
    follow: undefined,
    tracking: false,
    rows: 3,
    pitch: 6,
    length: 6,
    trail: TIMING.trail,
    stiffness: TIMING.stiffness,
    followStiffness: TIMING.followStiffness,
    reduced: false,
  };

  // Geometry, in CSS px. Column c's dots are centered at ox + c * pitch; the
  // run's ends are in column-edge units, so column c is lit when
  // start <= c + 0.5 < end.
  let dpr = 1;
  let width = 0;
  let height = 0;
  let cols = 0;
  let ox = 0;
  let glow = new Float32Array(0); // 0…1 per column
  let drawn = new Int16Array(0); // brightness step last drawn per column, -1 for none
  let palette: string[] = [];
  let halos: string[] = []; // the glow around a dot, per brightness step

  const start: Edge = { x: 0, v: 0, t: 0 };
  const end: Edge = { x: 0, v: 0, t: 0 };
  let placed = false; // the run has had a first target; until then it lands without travel
  let configured = false; // options have arrived from React; nothing is placed before that
  let ownsValueNow = false; // aria-valuenow was set here (scroll), not rendered by React
  let looping = false;
  let breathing = false;

  // What places the run.
  let scroller: HTMLElement | Window | null = null;
  let track: HTMLElement | null = null;
  let hovered: Element | null = null;
  let focused: Element | null = null;

  let raf = 0;
  let last = 0;
  let visible = false;
  const cleanups: (() => void)[] = [];
  let unbindScroll: (() => void) | null = null;
  let unbindTrack: (() => void) | null = null;

  function readColors() {
    if (!root) return;
    const css = getComputedStyle(root);
    const ink = readColor(css.color);
    const accent = readColor(css.getPropertyValue("--dot-rail-accent").trim() || RAIL.accent);
    const top = RAIL.levels - 1;
    palette = Array.from({ length: RAIL.levels }, (_, i) => {
      const g = i / top;
      const mix = (a: number, b: number) => Math.round(a + (b - a) * g);
      const alpha = ink[3] * RAIL.base * (1 - g) + accent[3] * g;
      return `rgba(${mix(ink[0], accent[0])}, ${mix(ink[1], accent[1])}, ${mix(ink[2], accent[2])}, ${alpha.toFixed(3)})`;
    });
    halos = Array.from({ length: RAIL.levels }, (_, i) => {
      const g = i / top;
      return `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${(accent[3] * RAIL.halo.alpha * g * g).toFixed(3)})`;
    });
    drawn.fill(-1);
  }

  function layout() {
    if (!canvas || !ctx) return;
    dpr = Math.min(window.devicePixelRatio || 1, RAIL.maxDpr);
    width = canvas.clientWidth;
    height = canvas.clientHeight;
    canvas.width = Math.max(1, Math.round(width * dpr));
    canvas.height = Math.max(1, Math.round(height * dpr));
    const pitch = options.pitch;
    cols = Math.max(1, Math.floor(width / pitch));
    ox = (width - cols * pitch) / 2 + pitch / 2;
    glow = new Float32Array(cols);
    drawn = new Int16Array(cols).fill(-1);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // A new width moves every target: land on them without travel, lit already.
    placed = false;
    resolve();
    wake();
  }

  const lit = (c: number) => c + 0.5 >= start.x && c + 0.5 < end.x;

  /** Left and right edge of an element, in column-edge units. */
  function span(el: Element): [number, number] {
    if (!canvas) return [0, 0];
    const box = canvas.getBoundingClientRect();
    const r = el.getBoundingClientRect();
    const u = (x: number) => (x - box.left - ox) / options.pitch + 0.5;
    let a = u(r.left);
    let b = u(r.right);
    if (b - a < 1) {
      const mid = (a + b) / 2;
      a = mid - 0.5;
      b = mid + 0.5;
    }
    return [a, b];
  }

  function progress() {
    if (!scroller) return 0;
    if (scroller instanceof Window) {
      const max = document.documentElement.scrollHeight - window.innerHeight;
      return max > 0 ? clamp01(window.scrollY / max) : 0;
    }
    const max = scroller.scrollHeight - scroller.clientHeight;
    return max > 0 ? clamp01(scroller.scrollTop / max) : 0;
  }

  /** A run of `length` dots at p along the rail: 0 sits at the left end, 1 at the right. */
  function at(p: number): [number, number] {
    const len = Math.min(options.length, cols);
    const a = clamp01(p) * (cols - len);
    return [a, a + len];
  }

  function aim(a: number, b: number) {
    start.t = a;
    end.t = b;
    if (!placed || options.reduced) {
      // Land without travel, already lit, so the first frame is the rest state.
      const landing = !placed;
      start.x = a;
      end.x = b;
      start.v = end.v = 0;
      placed = true;
      if (landing) for (let c = 0; c < cols; c++) glow[c] = lit(c) ? 1 : 0;
    }
  }

  /** Work out where the run belongs now, from the highest-priority source that has an answer. */
  function resolve() {
    if (!root || !cols || !configured) return;
    looping = breathing = false;
    // The sources bind in effects after the options land; don't place the run
    // for a source that isn't there yet, or it would travel in from nowhere.
    if (options.tracking && !track) return;
    if (options.follow === "scroll" && !scroller) return;

    const active = track?.querySelector(ACTIVE);
    const current = active ? (active.closest(ITEM) ?? active.querySelector(ITEM) ?? active) : null;
    const item = hovered ?? focused ?? current;
    if (item && track?.contains(item)) {
      const [a, b] = span(item);
      // A timer counts only under the current item; one pointed at shows whole.
      const share = item === current && item !== hovered && item !== focused ? clamp01(options.fill) : 1;
      aim(a, a + (b - a) * share);
    } else if (options.follow === "scroll") {
      const p = progress();
      root.setAttribute("aria-valuenow", String(Math.round(p * 100)));
      ownsValueNow = true;
      const [a, b] = at(p);
      aim(a, b);
    } else if (options.follow === "loop" || (options.follow === undefined && options.value === undefined && !options.tracking)) {
      if (options.reduced) {
        breathing = true;
        aim(...at(0.5));
      } else {
        // Carry on from wherever the run is, or come in from the left on a first run.
        looping = true;
        if (!placed) aim(-options.length, 0);
        end.t = start.t + Math.min(options.length, cols);
      }
    } else if (typeof options.value === "number") {
      aim(...at(options.value));
    } else if (Array.isArray(options.value)) {
      const lo = clamp01(Math.min(options.value[0], options.value[1]));
      const hi = clamp01(Math.max(options.value[0], options.value[1]));
      aim(lo * cols, hi * cols);
    } else if (placed) {
      // Nothing current: the run closes up where it stands.
      const mid = (start.x + end.x) / 2;
      aim(mid, mid);
    } else {
      // Dark from the start, without counting as placed, so the first real
      // target lands where it is instead of growing out of the middle.
      start.x = end.x = start.t = end.t = cols / 2;
    }
    if (options.follow !== "scroll" && ownsValueNow) {
      root.removeAttribute("aria-valuenow");
      ownsValueNow = false;
    }
    wake();
  }

  function drawColumn(c: number, level: number) {
    if (!ctx) return;
    const p = options.pitch * dpr;
    const x = (ox + c * options.pitch) * dpr;
    const r = RAIL.radius * p;
    ctx.clearRect(x - p / 2, 0, p, height * dpr);
    if (level > 0) {
      // The glow fills the dot's own cell and no more, so redrawing one column never clips a neighbor's.
      const halo = RAIL.halo.radius * p;
      ctx.fillStyle = halos[level];
      ctx.beginPath();
      for (let row = 0; row < options.rows; row++) {
        const y = (row + 0.5) * p;
        ctx.moveTo(x + halo, y);
        ctx.arc(x, y, halo, 0, Math.PI * 2);
      }
      ctx.fill();
    }
    ctx.fillStyle = palette[level];
    ctx.beginPath();
    for (let row = 0; row < options.rows; row++) {
      const y = (row + 0.5) * p;
      ctx.moveTo(x + r, y);
      ctx.arc(x, y, r, 0, Math.PI * 2);
    }
    ctx.fill();
  }

  function frame(time: number) {
    raf = 0;
    if (!ctx || !palette.length) return;
    const dt = last ? Math.min(TIMING.maxFrame, (time - last) / 1000) : 1 / 60;
    last = time;
    let busy = looping || breathing;

    if (looping) {
      const len = Math.min(options.length, cols);
      start.t += TIMING.loop * dt;
      end.t = start.t + len;
      // Wrap once the run is fully off the right end, moving targets and
      // springs together so nothing sweeps back across.
      if (start.x > cols) {
        const shift = cols + len;
        start.x -= shift;
        end.x -= shift;
        start.t -= shift;
        end.t -= shift;
      }
    }
    const spring = (grows: boolean) => (grows ? options.stiffness : options.followStiffness);
    if (stepEdge(start, dt, spring(start.t < start.x))) busy = true;
    if (stepEdge(end, dt, spring(end.t > end.x))) busy = true;

    const top = RAIL.levels - 1;
    const fade = options.reduced || options.trail <= 0 ? 0 : Math.exp(-dt / (options.trail / 1000 / 3));
    const peak = breathing
      ? RAIL.breathe.low + (1 - RAIL.breathe.low) * (0.5 + 0.5 * Math.cos((2 * Math.PI * time) / 1000 / TIMING.breathe))
      : 1;
    for (let c = 0; c < cols; c++) {
      let g = glow[c];
      if (lit(c)) {
        if (g < 1) {
          g = options.reduced ? 1 : Math.min(1, g + dt / TIMING.rise);
          busy = true;
        }
      } else if (g > 0) {
        g *= fade;
        if (g < RAIL.floor) g = 0;
        busy = true;
      }
      glow[c] = g;
      const level = Math.round((lit(c) ? g * peak : g) * top);
      if (level !== drawn[c]) {
        drawColumn(c, level);
        drawn[c] = level;
      }
    }

    if (busy && visible && !document.hidden) raf = requestAnimationFrame(frame);
    else last = 0;
  }

  function wake() {
    if (raf || !visible || !ctx || document.hidden) return;
    raf = requestAnimationFrame(frame);
  }

  return {
    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement }) {
      root = el.root;
      canvas = el.canvas;
      ctx = canvas.getContext("2d");
      readColors();
      layout();

      const resize = new ResizeObserver(() => {
        if (!canvas) return;
        if (canvas.clientWidth === width && canvas.clientHeight === height) return;
        layout();
      });
      resize.observe(canvas);
      const io = new IntersectionObserver(([entry]) => {
        visible = entry.isIntersecting;
        if (visible) wake();
      });
      io.observe(canvas);

      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      const recolor = () => {
        readColors();
        wake();
      };
      scheme.addEventListener("change", recolor);
      const themeAttrs = new MutationObserver(recolor);
      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme"] });

      const pageVisibility = () => {
        if (!document.hidden) wake();
      };
      document.addEventListener("visibilitychange", pageVisibility);

      cleanups.push(
        () => resize.disconnect(),
        () => io.disconnect(),
        () => scheme.removeEventListener("change", recolor),
        () => themeAttrs.disconnect(),
        () => document.removeEventListener("visibilitychange", pageVisibility),
      );
      return () => {
        cancelAnimationFrame(raf);
        raf = 0;
        last = 0;
        visible = false;
        cleanups.splice(0).forEach((fn) => fn());
        root = canvas = null;
        ctx = null;
      };
    },

    setOptions(next: Options) {
      const prev = options;
      options = next;
      if (!ctx) return;
      if (!configured) {
        configured = true;
        layout();
        return;
      }
      if (next.rows !== prev.rows || next.pitch !== prev.pitch) {
        layout();
        return;
      }
      if (next.reduced && !prev.reduced) {
        // Settle at once: no travel, no tail.
        start.x = start.t;
        end.x = end.t;
        start.v = end.v = 0;
      }
      resolve();
    },

    bindScroll(target: HTMLElement | Window | null) {
      unbindScroll?.();
      unbindScroll = null;
      scroller = target;
      if (target) {
        const onScroll = () => resolve();
        target.addEventListener("scroll", onScroll, { passive: true });
        window.addEventListener("resize", onScroll);
        unbindScroll = () => {
          target.removeEventListener("scroll", onScroll);
          window.removeEventListener("resize", onScroll);
        };
      }
      resolve();
    },

    bindTrack(el: HTMLElement | null) {
      unbindTrack?.();
      unbindTrack = null;
      track = el;
      hovered = focused = null;
      if (el) {
        const itemOf = (target: EventTarget | null) => {
          const item = target instanceof Element ? target.closest(ITEM) : null;
          return item && el.contains(item) ? item : null;
        };
        const over = (e: PointerEvent) => {
          if (e.pointerType === "touch") return;
          const item = itemOf(e.target);
          if (item && item !== hovered) {
            hovered = item;
            resolve();
          }
        };
        const leave = () => {
          if (!hovered) return;
          hovered = null;
          resolve();
        };
        const focusIn = (e: FocusEvent) => {
          const item = itemOf(e.target);
          // Keyboard focus only: a click focuses a link too, and the run should
          // go back to the current item, not stay where the mouse clicked.
          focused = item && item.matches(":focus-visible") ? item : null;
          resolve();
        };
        const focusOut = (e: FocusEvent) => {
          if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;
          focused = null;
          resolve();
        };
        el.addEventListener("pointerover", over);
        el.addEventListener("pointerleave", leave);
        el.addEventListener("focusin", focusIn);
        el.addEventListener("focusout", focusOut);

        // The current item can change, and items move when the nav reflows or a
        // web font lands.
        const sizes = new ResizeObserver(() => resolve());
        const observeItems = () => {
          sizes.disconnect();
          sizes.observe(el);
          el.querySelectorAll(ITEM).forEach((item) => sizes.observe(item));
        };
        observeItems();
        const changes = new MutationObserver((records) => {
          if (records.some((r) => r.type === "childList")) observeItems();
          resolve();
        });
        changes.observe(el, {
          subtree: true,
          childList: true,
          attributes: true,
          attributeFilter: ["aria-current", "data-active", "aria-selected"],
        });

        unbindTrack = () => {
          el.removeEventListener("pointerover", over);
          el.removeEventListener("pointerleave", leave);
          el.removeEventListener("focusin", focusIn);
          el.removeEventListener("focusout", focusOut);
          sizes.disconnect();
          changes.disconnect();
        };
      }
      resolve();
    },

    disconnectSources() {
      unbindScroll?.();
      unbindTrack?.();
      unbindScroll = unbindTrack = null;
      scroller = null;
      track = hovered = focused = null;
    },
  };
}

type Rail = ReturnType<typeof createRail>;

export interface DotRailProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** Where the run sits: a number from 0 (the left end) to 1 (the right end), or a `[from, to]` range to light, such as `[0, 1]` for the whole rail. `follow` and a hovered `trackRef` item take precedence. */
  value?: number | [number, number];
  /** Let the run place itself: `"scroll"` sits it at the scroll progress of the page (or `containerRef`), `"loop"` sends it round the rail on its own, like a loader. With no `follow`, `value` or `trackRef`, it loops. */
  follow?: Follow;
  /** The scrolling element whose progress `follow="scroll"` reads. Defaults to the page. */
  containerRef?: React.RefObject<HTMLElement | null>;
  /** A nav, tab list or menu to follow: the run jumps under whichever of its links or buttons is hovered or focused, as wide as it is, and goes back to the one marked `aria-current` (or `data-active`, or `aria-selected`) when the pointer leaves. */
  trackRef?: React.RefObject<HTMLElement | null>;
  /** With `trackRef`, how much of the current item is lit, from its left edge, from 0 to 1. Drive it from a timer and the dots under an autoplaying tab count up one by one. */
  fill?: number;
  /** Rows of dots in the band. */
  rows?: number;
  /** Distance between dot centers, in px. The rail is `rows × pitch` tall. */
  pitch?: number;
  /** How many dots wide the run is when it's a position (a number `value`, scroll or loop). */
  length?: number;
  /** How long, in ms, a dot keeps glowing after the run leaves it. `0` turns the tail off. */
  trail?: number;
  /** Spring stiffness of the end that leads the run when it moves. Higher is snappier. */
  stiffness?: number;
  /** Spring stiffness of the end that catches up behind it. Lower than `stiffness` and the run stretches toward where it's going. */
  followStiffness?: number;
}

export function DotRail({
  value,
  follow,
  containerRef,
  trackRef,
  fill = 1,
  rows = 3,
  pitch = 6,
  length = 6,
  trail = TIMING.trail,
  stiffness = TIMING.stiffness,
  followStiffness = TIMING.followStiffness,
  className,
  style,
  ...props
}: DotRailProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const [rail] = React.useState<Rail>(createRail);
  const reduced = useReducedMotion() ?? false;

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const disconnect = rail.connect({ root, canvas });
    return () => {
      rail.disconnectSources();
      disconnect();
    };
  }, [rail]);

  // A range arrives as a new array each render; compare its ends instead.
  const from = Array.isArray(value) ? value[0] : value;
  const to = Array.isArray(value) ? value[1] : undefined;
  React.useEffect(() => {
    rail.setOptions({
      value: from === undefined ? undefined : to === undefined ? from : [from, to],
      fill,
      follow,
      tracking: trackRef !== undefined,
      rows: Math.max(1, Math.round(rows)),
      pitch: Math.max(2, pitch),
      length: Math.max(1, Math.round(length)),
      trail: Math.max(0, trail),
      stiffness: Math.max(1, stiffness),
      followStiffness: Math.max(1, followStiffness),
      reduced,
    });
  }, [rail, from, to, fill, follow, trackRef, rows, pitch, length, trail, stiffness, followStiffness, reduced]);

  // Refs are attached by the time effects run, including a parent's.
  React.useEffect(() => {
    rail.bindScroll(follow === "scroll" ? (containerRef?.current ?? window) : null);
  }, [rail, follow, containerRef]);
  React.useEffect(() => {
    rail.bindTrack(trackRef?.current ?? null);
  }, [rail, trackRef]);

  const position = follow === "scroll" || (follow === undefined && typeof value === "number");
  return (
    <div
      ref={rootRef}
      role={position ? "progressbar" : undefined}
      aria-label={position ? (follow === "scroll" ? "Scroll progress" : "Progress") : undefined}
      aria-valuemin={position ? 0 : undefined}
      aria-valuemax={position ? 100 : undefined}
      aria-valuenow={follow === undefined && typeof value === "number" ? Math.round(clamp01(value) * 100) : undefined}
      className={cn("relative w-full", className)}
      style={{ height: Math.max(1, Math.round(rows)) * Math.max(2, pitch), ...style }}
      {...props}
    >
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 size-full" />
    </div>
  );
}
```
