# Dot Rail Feature

> A feature section that walks a release through its five stations as tabs that play by themselves. The LED dots under the current station count up like a timer before the next one comes up; point at a station and the lit run jumps under it, pick one and it stays. Each station shows what happens there, with the product's own readout beside it.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/dot-rail-feature).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/dot-rail-feature
- Screenshot: https://shadcn.exchange/shot/keyframeui/dot-rail-feature/light.png
- Registry item: https://keyframeui.com/r/dot-rail-feature.json
- Categories: blocks, feature-sections
- Listed: 2026-11-02, source last changed 2026-11-02

## Install

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

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://keyframeui.com/r/dot-rail.json`, `utils`

## Notes from the registry

Live preview and the components it's built with: https://keyframeui.com/blocks/feature-sections/dot-rail-feature

## Files

### `registry/blocks/dot-rail-feature.tsx`

```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";

/* ── Content ─────────────────────────────────────────────────
 * Everything you'd change to make the section yours: the
 * palette, the copy and the stations. The stations play by
 * themselves: the dots under the current one count up like a
 * timer, then the next one comes up. Point at a station and
 * the rail jumps under it; pick one and it stays.
 * ─────────────────────────────────────────────────────────── */

/** Paper and ink, an electric blue for the headline's italic, and a hot signal color for the rail's lit dots. */
const PALETTE = {
  light: { paper: "#fafaf9", ink: "#0b0b0d", accent: "#2747ff", signal: "#ff5a1f", platform: "#ffffff" },
  dark: { paper: "#07080c", ink: "#f4f4f6", accent: "#8292ff", signal: "#ff6a2b", platform: "#10121a" },
};

const HEADLINE = { before: "Watch a release", accent: "ride", after: "the line" };
const BODY =
  "Every Tuesday's train calls at the same five stations. Pick one to see what Milepost does there, or let it run.";
const TABS_LABEL = "Release stations";
/** ms each station stays up while the section plays by itself. */
const DWELL = 4500;
const TICK = 80;

type Row = { name: string; value: string; state: "done" | "live" | "wait" };
type Station = { id: string; label: string; time: string; title: string; body: string; rows: Row[] };

const STATIONS: Station[] = [
  {
    id: "cut",
    label: "Branch cut",
    time: "Tue 10:00",
    title: "The doors close on time",
    body: "At ten the release branch is cut from main. Anything merged after rides next week, and nobody has to ask.",
    rows: [
      { name: "release/214", value: "cut from main at 4f1c9e2", state: "done" },
      { name: "Aboard", value: "38 changes from 4 teams", state: "done" },
      { name: "Next cut", value: "Tue 10:00", state: "wait" },
    ],
  },
  {
    id: "checks",
    label: "Checks",
    time: "10:04",
    title: "Every change shows its ticket",
    body: "Tests, migrations and the bundle budget run on the branch itself, so a red check names the change that broke it.",
    rows: [
      { name: "Unit", value: "2,914 passed", state: "done" },
      { name: "End to end", value: "186 passed", state: "done" },
      { name: "Migrations", value: "2, both reversible", state: "done" },
    ],
  },
  {
    id: "staging",
    label: "Staging",
    time: "10:31",
    title: "A dress rehearsal with real data",
    body: "The train stops on staging for an hour. The teams who own the changes get a link and a checklist, not a meeting.",
    rows: [
      { name: "Payments", value: "signed off", state: "done" },
      { name: "Search", value: "signed off", state: "done" },
      { name: "Mobile", value: "checking now", state: "live" },
    ],
  },
  {
    id: "canary",
    label: "10% of users",
    time: "11:15",
    title: "A canary that can stop the train",
    body: "One in ten requests rides the new release. If errors or latency drift past your limits, it rolls back by itself.",
    rows: [
      { name: "Error rate", value: "0.21%, limit 0.5%", state: "done" },
      { name: "p95 latency", value: "184 ms, limit 250 ms", state: "done" },
      { name: "Auto-rollback", value: "armed", state: "live" },
    ],
  },
  {
    id: "everyone",
    label: "Everyone",
    time: "13:00",
    title: "Arrived, with the notes written",
    body: "The release reaches everyone by lunch, and the changelog is drafted from the tickets that rode it.",
    rows: [
      { name: "Rollout", value: "100%", state: "done" },
      { name: "Changelog", value: "drafted, 38 entries", state: "done" },
      { name: "Train 215", value: "boarding", state: "wait" },
    ],
  },
];

/* ── Section ─────────────────────────────────────────────── */

export function DotRailFeature() {
  const sectionRef = React.useRef<HTMLElement>(null);
  const tabsRef = React.useRef<HTMLDivElement>(null);
  const inView = useInView(sectionRef, { amount: 0.35 });
  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) % STATIONS.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 = STATIONS.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 (
    <section
      ref={sectionRef}
      style={
        {
          "--paper-light": PALETTE.light.paper,
          "--ink-light": PALETTE.light.ink,
          "--accent-light": PALETTE.light.accent,
          "--signal-light": PALETTE.light.signal,
          "--platform-light": PALETTE.light.platform,
          "--paper-dark": PALETTE.dark.paper,
          "--ink-dark": PALETTE.dark.ink,
          "--accent-dark": PALETTE.dark.accent,
          "--signal-dark": PALETTE.dark.signal,
          "--platform-dark": PALETTE.dark.platform,
        } as React.CSSProperties
      }
      className="bg-(--paper) text-(--ink) [--accent:var(--accent-light)] [--dot-rail-accent:var(--signal)] [--ink:var(--ink-light)] [--paper:var(--paper-light)] [--platform:var(--platform-light)] [--signal:var(--signal-light)] dark:[--accent:var(--accent-dark)] dark:[--ink:var(--ink-dark)] dark:[--paper:var(--paper-dark)] dark:[--platform:var(--platform-dark)] dark:[--signal:var(--signal-dark)]"
    >
      <div className="mx-auto max-w-6xl px-6 py-24 sm:py-28">
        <div className="grid gap-5 md:grid-cols-[minmax(0,1fr)_minmax(0,24rem)] md:items-end md:gap-12">
          <h2 className="font-serif text-4xl leading-[1.05] font-normal tracking-[-0.02em] text-balance sm:text-5xl">
            {HEADLINE.before} <em className="text-(--accent)">{HEADLINE.accent}</em> {HEADLINE.after}
          </h2>
          <p className="text-base leading-relaxed text-pretty opacity-70">{BODY}</p>
        </div>

        <div
          className="mt-14"
          onPointerEnter={(e) => e.pointerType === "mouse" && setPaused(true)}
          onPointerLeave={(e) => e.pointerType === "mouse" && setPaused(false)}
        >
          <div ref={tabsRef} role="tablist" aria-label={TABS_LABEL} className="grid grid-cols-5">
            {STATIONS.map((station, index) => (
              <button
                key={station.id}
                id={`milepost-tab-${station.id}`}
                type="button"
                role="tab"
                aria-selected={index === active}
                aria-controls={`milepost-panel-${station.id}`}
                tabIndex={index === active ? 0 : -1}
                onClick={() => pick(index)}
                onKeyDown={(e) => onKey(e, index)}
                className="group rounded-md px-1 pb-3 text-left outline-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--accent)"
              >
                <span className="hidden font-mono text-[11px] tracking-[0.1em] uppercase tabular-nums opacity-45 sm:block">
                  {station.time}
                </span>
                <span className="block text-[13px] leading-tight font-medium sm:mt-1 opacity-45 transition-opacity duration-150 group-hover:opacity-100 group-aria-selected:opacity-100 sm:text-[15px]">
                  {station.label}
                </span>
              </button>
            ))}
          </div>
          <DotRail trackRef={tabsRef} fill={auto && !reduced ? elapsed / DWELL : 1} />

          <div className="mt-6 grid">
            {STATIONS.map((station, index) => (
              <div
                key={station.id}
                id={`milepost-panel-${station.id}`}
                role="tabpanel"
                aria-labelledby={`milepost-tab-${station.id}`}
                inert={index !== active}
                className={cn(
                  "col-start-1 row-start-1 rounded-2xl bg-(--platform) p-6 ring-1 ring-current/10 transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:translate-y-0 sm:p-8",
                  index === active ? "translate-y-0 opacity-100" : "pointer-events-none translate-y-1.5 opacity-0",
                )}
              >
                <Platform station={station} />
              </div>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

/* ── Platform ────────────────────────────────────────────── */

/** What happens at one station: what it does, and a strip of the product's own readout. */
function Platform({ station }: { station: Station }) {
  return (
    <div className="grid gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] md:items-center md:gap-10">
      <div>
        <h3 className="font-serif text-2xl leading-tight tracking-[-0.01em]">{station.title}</h3>
        <p className="mt-2 max-w-md text-[15px] leading-relaxed text-pretty opacity-70">{station.body}</p>
      </div>
      <ul className="divide-y divide-current/10 rounded-xl px-4 ring-1 ring-current/10">
        {station.rows.map((row) => (
          <li key={row.name} className="flex items-center gap-3 py-2.5 font-mono text-[12px]">
            <span
              aria-hidden
              className={
                row.state === "done"
                  ? "size-1.5 rounded-full bg-current"
                  : row.state === "live"
                    ? "size-1.5 rounded-full bg-(--signal)"
                    : "size-1.5 rounded-full ring-1 ring-current/40"
              }
            />
            <span className="opacity-60">{row.name}</span>
            <span className="ml-auto text-right tabular-nums">{row.value}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}
```
