Motion
ChapterScrubber
A rail of ticks, one per chapter, that swell like a dock under the pointer, with a card previewing the hovered chapter.
Guidelines#
Use it for jumping through a recording's chapters or a long agent run; currentIndex marks where playback or the agent is now.
What you provide (defaults in brackets):
chapters: { id, title, description?, meta? }[];metais a small label such as a timestamp.side["right"]: flips automatically near the viewport edge.peakLength[56],restLength[14] androwHeight[10], in px;radius[4] rows.currentIndex,onActiveChange(chapter | null, index),onSelect(chapter, index),label["Chapters"],className.
Anatomy:
- Ticks are
h-[2px] rounded-full bg-foreground, orbg-primaryfor the current one. They rest at 22% opacity (55% current) and reach 100% at the crest, thickening to 1.4×. - The card is
w-[260px] rounded-2xl border bg-popover px-4 py-3.5, 20px from the rail, with the description clamped to 3 lines.
Motion: the swell falls off along a raised-cosine curve. The pointer is tracked with a spring (stiffness 700, damping 52, mass 0.5), and the swell rises and settles with a softer one (260/30/0.6). Under reduced motion the springs are skipped and the swell appears instantly.
Accessibility: the rail is a listbox with one tabbable option at a time. Arrow keys, Home and End move focus; Enter or Space selects.
Don't pass essential text as a non-string description: the card is aria-hidden, and only string descriptions reach each option's accessible label.
Facts#
| Group | Motion |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.ChapterScrubber |
| Source | max-agent: src/components/ui/chapter-scrubber.tsx |
| Import in the app | import { ChapterScrubber } from "@/components/ui/chapter-scrubber"; |
API#
TypeScript declarations emitted from src/components/ui/chapter-scrubber.tsx.
export interface Chapter {
/** Stable, unique identifier for the chapter. */
id: string;
/** Bold heading shown at the top of the preview card. */
title: string;
/** Supporting copy shown under the title (clamped to three lines). */
description?: React.ReactNode;
/** Small muted label rendered above the title (e.g. a timestamp or step no.). */
meta?: React.ReactNode;
}
export interface ChapterScrubberProps {
/** Chapters rendered top-to-bottom, one uniform tick each. */
chapters: Chapter[];
/** Which side the preview card opens toward. Auto-flips near a viewport edge. Default `"right"`. */
side?: "left" | "right";
/** Length a tick reaches at the crest of the magnification, in pixels. Default `56`. */
peakLength?: number;
/** Resting length of every tick, in pixels. Keep it small. Default `14`. */
restLength?: number;
/** Height of each row in pixels; the gap between ticks. Smaller = denser. Default `10`. */
rowHeight?: number;
/** Radius of the magnification wave, in rows — how far the rise reaches from the pointer. Default `4`. */
radius?: number;
/** Marks one chapter as the persistent "current" position (e.g. where an agent is now). */
currentIndex?: number;
/** Fires when the active (hovered/focused) chapter changes. */
onActiveChange?: (chapter: Chapter | null, index: number) => void;
/** Fires when a chapter is chosen via click, Enter or Space. */
onSelect?: (chapter: Chapter, index: number) => void;
/** Accessible name for the rail. Default `"Chapters"`. */
label?: string;
className?: string;
}
export declare function ChapterScrubber({ chapters, side, peakLength, restLength, rowHeight, radius, currentIndex, onActiveChange, onSelect, label, className, }: ChapterScrubberProps): any;
export default ChapterScrubber;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
// Chapters of a recorded discovery call.
var CHAPTERS = [
["00:00", "Introductions", "Sarah opens; Claire and Tom join from Lyon."],
["01:40", "Why now", "SDR team doubled, reply rates fell; board wants 3× coverage."],
["04:05", "Current stack", "HubSpot sequences, 40 live; LinkedIn done by hand."],
["06:30", "How Max works", "Research, first touches, warm hand-off with context."],
["09:10", "Digital Workers", "Two workers on one segment to start."],
["11:45", "HubSpot sync", "Enrolments are read so nobody gets a double touch."],
["14:20", "Deliverability", "Warm-up, sending windows and bounce protection."],
["16:50", "Pricing", "Growth plan, monthly billing for the pilot."],
["19:15", "Objections", "January budget freeze; CEO sign-off for annual."],
["21:30", "Pilot scope", "DACH logistics, CFO and COO personas."],
["23:40", "Success metrics", "Reply rate and meetings booked per 100 leads."],
["25:55", "Security review", "SOC 2 report and DPA requested."],
["27:10", "Next steps", "Technical session Thursday at 10:00 with RevOps."],
["28:30", "Wrap-up", "Sarah sends pricing and benchmarks today."]
].map(function (c, i) { return { id: "ch-" + i, meta: c[0], title: c[1], description: c[2] }; });
function App() {
var ref = React.useRef(null);
var s = React.useState(null), active = s[0], setActive = s[1];
React.useEffect(function () {
// Hover over chapter 8 once so the card shows the preview.
var t = setTimeout(function () {
var list = ref.current && ref.current.querySelector("[role=listbox]");
if (!list) return;
var r = list.getBoundingClientRect();
list.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: r.left + 10, clientY: r.top + 7.5 * 10, pointerType: "mouse" }));
}, 200);
return function () { clearTimeout(t); };
}, []);
return h("div", { ref: ref, className: "flex items-start gap-10" },
h("div", { className: "w-48 shrink-0 space-y-1" },
h("p", { className: "text-sm font-semibold" }, "Northwind Traders intro call"),
h("p", { className: "text-xs text-muted-foreground" }, "29 min · 14 chapters · Max is on chapter 5"),
h("p", { className: "pt-2 text-xs text-muted-foreground" }, active ? "Hovering: " + active : "Hover or tab through the rail")),
h(DC.ChapterScrubber, {
chapters: CHAPTERS, currentIndex: 4, label: "Meeting chapters",
onActiveChange: function (c) { setActive(c ? c.title : null); },
onSelect: function () {}
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>