# Dot Rail: 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.

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-demo).

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

## Install

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

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

## Dependencies

- Registry items: `https://keyframeui.com/r/dot-rail.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/dot-rail#dot-rail-demo

## Files

### `registry/demos/dot-rail-demo.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";

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>
  );
}
```
