Design
llms.txt digitalcrew.tech

Motion

ChapterScrubber

A rail of ticks, one per chapter, that swell like a dock under the pointer, with a card previewing the hovered chapter.

MaxMotionDigitalCrew.ChapterScrubber
ChapterScrubber · liveOpen

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? }[]; meta is a small label such as a timestamp.
  • side ["right"]: flips automatically near the viewport edge.
  • peakLength [56], restLength [14] and rowHeight [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, or bg-primary for 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#

GroupMotion
ProductMax
Globalwindow.DigitalCrew.ChapterScrubber
Sourcemax-agent: src/components/ui/chapter-scrubber.tsx
Import in the appimport { 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>