{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "chapter-scrubber", "type": "registry:ui", "title": "Chapter Scrubber", "description": "A vertical rail of uniform ticks that magnify toward the cursor like a dock — the lines nearest the pointer rise on a spring-driven wave and a preview card describes the crest chapter, inspired by the OpenAI Codex chapters minimap.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/chapter-scrubber.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport {\n motion,\n useMotionValue,\n useReducedMotion,\n useSpring,\n useTransform,\n type MotionValue,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Chapter {\n /** Stable, unique identifier for the chapter. */\n id: string;\n /** Bold heading shown at the top of the preview card. */\n title: string;\n /** Supporting copy shown under the title (clamped to three lines). */\n description?: React.ReactNode;\n /** Small muted label rendered above the title (e.g. a timestamp or step no.). */\n meta?: React.ReactNode;\n}\n\nexport interface ChapterScrubberProps {\n /** Chapters rendered top-to-bottom, one uniform tick each. */\n chapters: Chapter[];\n /** Which side the preview card opens toward. Auto-flips near a viewport edge. Default `\"right\"`. */\n side?: \"left\" | \"right\";\n /** Length a tick reaches at the crest of the magnification, in pixels. Default `56`. */\n peakLength?: number;\n /** Resting length of every tick, in pixels. Keep it small. Default `14`. */\n restLength?: number;\n /** Height of each row in pixels; the gap between ticks. Smaller = denser. Default `10`. */\n rowHeight?: number;\n /** Radius of the magnification wave, in rows — how far the rise reaches from the pointer. Default `4`. */\n radius?: number;\n /** Marks one chapter as the persistent \"current\" position (e.g. where an agent is now). */\n currentIndex?: number;\n /** Fires when the active (hovered/focused) chapter changes. */\n onActiveChange?: (chapter: Chapter | null, index: number) => void;\n /** Fires when a chapter is chosen via click, Enter or Space. */\n onSelect?: (chapter: Chapter, index: number) => void;\n /** Accessible name for the rail. Default `\"Chapters\"`. */\n label?: string;\n className?: string;\n}\n\nconst CARD_WIDTH = 260;\nconst GAP = 20;\n// Tight, near-critically-damped spring: tracks the cursor with almost no lag\n// and never overshoots — the wave feels attached to the pointer.\nconst POINTER_SPRING = { stiffness: 700, damping: 52, mass: 0.5 };\n// Softer spring for the rise/settle so the wave swells and relaxes gracefully.\nconst STRENGTH_SPRING = { stiffness: 260, damping: 30, mass: 0.6 };\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max);\n}\n\n// Raised-cosine bump: 1 at the crest, 0 beyond the radius, with zero slope at\n// both ends so the wave has no seams — the source of the buttery falloff.\nfunction bump(distance: number, radius: number) {\n if (distance >= radius) return 0;\n return 0.5 * (1 + Math.cos(Math.PI * (distance / radius)));\n}\n\ninterface TickProps {\n index: number;\n pointer: MotionValue;\n strength: MotionValue;\n radius: number;\n restLength: number;\n peakLength: number;\n isCurrent: boolean;\n}\n\nconst Tick = React.memo(function Tick({\n index,\n pointer,\n strength,\n radius,\n restLength,\n peakLength,\n isCurrent,\n}: TickProps) {\n const width = useTransform(() => {\n const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n return restLength + rise * (peakLength - restLength);\n });\n const opacity = useTransform(() => {\n const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n const base = isCurrent ? 0.55 : 0.22;\n return base + rise * (1 - base);\n });\n const scaleY = useTransform(() => {\n const rise = strength.get() * bump(Math.abs(index - pointer.get()), radius);\n // Only a slight thickening at the crest (2px -> ~2.8px); the length change\n // carries the rise, thickness is a quiet secondary cue.\n return 1 + rise * 0.4;\n });\n\n return (\n \n );\n});\n\nexport function ChapterScrubber({\n chapters,\n side = \"right\",\n peakLength = 56,\n restLength = 14,\n rowHeight = 10,\n radius = 4,\n currentIndex,\n onActiveChange,\n onSelect,\n label = \"Chapters\",\n className,\n}: ChapterScrubberProps) {\n const prefersReducedMotion = useReducedMotion();\n const containerRef = React.useRef(null);\n const listRef = React.useRef(null);\n const cardRef = React.useRef(null);\n const buttonsRef = React.useRef>([]);\n // Namespaced so option ids stay unique across instances and don't depend on\n // chapter.id being a valid, collision-free DOM id.\n const baseId = React.useId();\n const optionId = (index: number) => `${baseId}-opt-${index}`;\n\n const rawPointer = useMotionValue(0);\n const rawStrength = useMotionValue(0);\n const springPointer = useSpring(rawPointer, POINTER_SPRING);\n const springStrength = useSpring(rawStrength, STRENGTH_SPRING);\n // Reduced motion: drop the temporal easing but keep the spatial wave, so the\n // rise is instant rather than sprung.\n const pointer = prefersReducedMotion ? rawPointer : springPointer;\n const strength = prefersReducedMotion ? rawStrength : springStrength;\n\n const [activeIndex, setActiveIndex] = React.useState(0);\n const [engaged, setEngaged] = React.useState(false);\n const [flipped, setFlipped] = React.useState(false);\n const [cardHeight, setCardHeight] = React.useState(0);\n const hoveringRef = React.useRef(false);\n const focusedRef = React.useRef(null);\n const activeRef = React.useRef(0);\n\n const commitActive = React.useCallback((index: number) => {\n if (index !== activeRef.current) {\n activeRef.current = index;\n setActiveIndex(index);\n }\n }, []);\n\n const last = chapters.length - 1;\n\n React.useEffect(() => {\n onActiveChange?.(\n engaged ? chapters[activeIndex] : null,\n engaged ? activeIndex : -1,\n );\n }, [engaged, activeIndex, chapters, onActiveChange]);\n\n // Measure the card so its vertical travel can be clamped to the rail.\n React.useEffect(() => {\n if (cardRef.current) setCardHeight(cardRef.current.offsetHeight);\n }, [activeIndex]);\n\n // Flip toward the roomier side if the card would spill past the viewport.\n React.useEffect(() => {\n if (!engaged) return;\n const el = containerRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const vw = el.ownerDocument.defaultView?.innerWidth ?? 0;\n const need = CARD_WIDTH + GAP + 8;\n let useRight = side === \"right\";\n if (useRight && vw - rect.right < need && rect.left >= need)\n useRight = false;\n if (!useRight && rect.left < need && vw - rect.right >= need)\n useRight = true;\n setFlipped(useRight !== (side === \"right\"));\n }, [engaged, activeIndex, side]);\n\n const resolvedSide =\n side === \"right\"\n ? flipped\n ? \"left\"\n : \"right\"\n : flipped\n ? \"right\"\n : \"left\";\n\n const totalHeight = chapters.length * rowHeight;\n // Exactly one tick is tabbable at a time (roving tabindex).\n const rovingIndex = engaged ? activeIndex : (currentIndex ?? 0);\n\n const cardTop = useTransform(pointer, (p) => {\n const half = cardHeight / 2;\n const center = clamp(\n (p + 0.5) * rowHeight,\n half,\n Math.max(half, totalHeight - half),\n );\n return center - half;\n });\n const cardScale = useTransform(strength, [0, 1], [0.97, 1]);\n const cardX = useTransform(\n strength,\n [0, 1],\n [resolvedSide === \"right\" ? -6 : 6, 0],\n );\n\n const engageAt = (pointerRow: number, activeAt: number) => {\n rawPointer.set(pointerRow);\n rawStrength.set(1);\n commitActive(clamp(activeAt, 0, last));\n if (!engaged) setEngaged(true);\n };\n\n const handlePointerMove = (event: React.PointerEvent) => {\n const el = listRef.current;\n if (!el) return;\n const rect = el.getBoundingClientRect();\n const row = (event.clientY - rect.top) / rowHeight - 0.5;\n hoveringRef.current = true;\n engageAt(clamp(row, -0.5, last + 0.5), Math.round(row));\n };\n\n const handlePointerLeave = () => {\n hoveringRef.current = false;\n if (focusedRef.current != null) {\n rawPointer.set(focusedRef.current);\n } else {\n rawStrength.set(0);\n setEngaged(false);\n }\n };\n\n const handleBlur = (event: React.FocusEvent) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n focusedRef.current = null;\n if (!hoveringRef.current) {\n rawStrength.set(0);\n setEngaged(false);\n }\n }\n };\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n let next = focusedRef.current ?? activeRef.current;\n switch (event.key) {\n case \"ArrowDown\":\n case \"ArrowRight\":\n next = Math.min(last, next + 1);\n break;\n case \"ArrowUp\":\n case \"ArrowLeft\":\n next = Math.max(0, next - 1);\n break;\n case \"Home\":\n next = 0;\n break;\n case \"End\":\n next = last;\n break;\n default:\n return;\n }\n event.preventDefault();\n buttonsRef.current[next]?.focus();\n };\n\n return (\n \n \n {chapters.map((chapter, index) => {\n const isCurrent = index === currentIndex;\n const descText =\n typeof chapter.description === \"string\"\n ? `. ${chapter.description}`\n : \"\";\n return (\n {\n buttonsRef.current[index] = el;\n }}\n key={chapter.id}\n id={optionId(index)}\n type=\"button\"\n role=\"option\"\n aria-selected={isCurrent}\n aria-label={`${chapter.title}${descText}`}\n tabIndex={index === rovingIndex ? 0 : -1}\n onFocus={() => {\n focusedRef.current = index;\n engageAt(index, index);\n }}\n onClick={() => onSelect?.(chapter, index)}\n style={{ height: rowHeight }}\n className={cn(\n \"flex w-full items-center rounded-sm outline-none\",\n \"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\",\n resolvedSide === \"left\" ? \"justify-end\" : \"justify-start\",\n )}\n >\n \n \n );\n })}\n \n\n {chapters[activeIndex] ? (\n \n {chapters[activeIndex].meta ? (\n
\n {chapters[activeIndex].meta}\n
\n ) : null}\n
\n {chapters[activeIndex].title}\n
\n {chapters[activeIndex].description ? (\n

\n {chapters[activeIndex].description}\n

\n ) : null}\n \n ) : null}\n \n );\n}\n\nexport default ChapterScrubber;\n", "type": "registry:ui", "target": "components/ruixen/chapter-scrubber.tsx" } ] }