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.
registry/demos/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>
);
}
Live preview, examples and props: https://keyframeui.com/components/dot-rail#dot-rail-demo