{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chapter-scrubber",
  "title": "Chapter scrubber",
  "author": "Tori Bryan",
  "description": "A rail of marks that swell under the pointer like the Dock, previewing the chapter at the crest.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/chapter-scrubber.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport {\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\"\n\nimport { mergeRefs } from \"@/lib/merge-refs\"\nimport { cn } from \"@/lib/utils\"\n\ntype Chapter = {\n  /** Stable, unique identifier. */\n  id: string\n  /** Heading in the preview. */\n  title: string\n  /** Supporting copy under the title, clamped to three lines. */\n  description?: React.ReactNode\n  /** Small muted label beside the title, such as a timestamp or step number. */\n  meta?: React.ReactNode\n}\n\ntype Orientation = \"vertical\" | \"horizontal\"\ntype Side = \"left\" | \"right\" | \"top\" | \"bottom\"\n\ntype Metrics = {\n  /** Row pitch along the rail: the gap between marks. */\n  rowSize: number\n  /** Resting length of a tick, or diameter of a dot. */\n  rest: number\n  /** Length or diameter at the crest of the wave. */\n  peak: number\n  /** Tick thickness. Dots ignore it. */\n  thickness: number\n}\n\nconst SIZES: Record<\n  \"tick\" | \"dot\",\n  Record<\"sm\" | \"default\" | \"lg\", Metrics>\n> = {\n  tick: {\n    sm: { rowSize: 8, rest: 10, peak: 40, thickness: 1.5 },\n    default: { rowSize: 10, rest: 14, peak: 56, thickness: 2 },\n    lg: { rowSize: 14, rest: 18, peak: 72, thickness: 2 },\n  },\n  dot: {\n    sm: { rowSize: 12, rest: 3, peak: 9, thickness: 0 },\n    default: { rowSize: 16, rest: 4, peak: 12, thickness: 0 },\n    lg: { rowSize: 20, rest: 5, peak: 16, thickness: 0 },\n  },\n}\n\nconst CARD_WIDTH = 248\nconst LABEL_MAX_WIDTH = 220\nconst GAP = 16\n// The preview keeps this far from the viewport's edge, flipping to the\n// rail's other side, narrowing or sliding along it to do so.\nconst VIEWPORT_MARGIN = 8\n// Measured sizes stand in for these until the card first renders.\nconst CARD_FALLBACK_HEIGHT = 120\n\n// Near-critically damped: the crest tracks the pointer with almost no lag and\n// never overshoots, so the wave reads as attached to it.\nconst POINTER_SPRING = { stiffness: 700, damping: 52, mass: 0.5 }\n// Softer, so the wave swells in and relaxes rather than snapping.\nconst STRENGTH_SPRING = { stiffness: 260, damping: 30, mass: 0.6 }\n\nfunction subscribeToNothing() {\n  return () => {}\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max)\n}\n\n// Raised cosine: 1 at the crest, 0 past the radius, with zero slope at both\n// ends so the wave has no visible seam where it meets the resting marks.\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\ntype MarkProps = {\n  index: number\n  pointer: MotionValue<number>\n  strength: MotionValue<number>\n  radius: number\n  metrics: Metrics\n  variant: \"tick\" | \"dot\"\n  orientation: Orientation\n  isCurrent: boolean\n}\n\nconst Mark = React.memo(function Mark({\n  index,\n  pointer,\n  strength,\n  radius,\n  metrics,\n  variant,\n  orientation,\n  isCurrent,\n}: MarkProps) {\n  const rise = useTransform(\n    () => strength.get() * bump(Math.abs(index - pointer.get()), radius)\n  )\n  const length = useTransform(\n    rise,\n    (r) => metrics.rest + r * (metrics.peak - metrics.rest)\n  )\n  const opacity = useTransform(rise, (r) => {\n    const base = isCurrent ? 0.9 : 0.25\n    return base + r * (1 - base)\n  })\n  // A quiet second cue: ticks thicken slightly at the crest while the length\n  // carries the rise.\n  const thicken = useTransform(rise, (r) => 1 + r * 0.4)\n\n  if (variant === \"dot\") {\n    return (\n      <motion.span\n        aria-hidden=\"true\"\n        data-current={isCurrent ? \"\" : undefined}\n        style={{ width: length, height: length, opacity }}\n        className=\"block shrink-0 rounded-full bg-foreground\"\n      />\n    )\n  }\n\n  const vertical = orientation === \"vertical\"\n  return (\n    <motion.span\n      aria-hidden=\"true\"\n      data-current={isCurrent ? \"\" : undefined}\n      style={\n        vertical\n          ? {\n              width: length,\n              height: metrics.thickness,\n              opacity,\n              scaleY: thicken,\n            }\n          : {\n              height: length,\n              width: metrics.thickness,\n              opacity,\n              scaleX: thicken,\n            }\n      }\n      className=\"block shrink-0 rounded-full bg-foreground\"\n    />\n  )\n})\n\ntype ChapterScrubberProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** Chapters in order along the rail, one mark each. */\n  chapters: Chapter[]\n  /** Which way the rail runs. */\n  orientation?: Orientation\n  /** Mark style: hairline ticks or dots. */\n  variant?: \"tick\" | \"dot\"\n  /** Density preset. Numeric props below override it. */\n  size?: \"sm\" | \"default\" | \"lg\"\n  /**\n   * Where the preview opens. Vertical rails take left or right, horizontal\n   * rails top or bottom. Flips when the preferred side would leave the\n   * viewport.\n   */\n  side?: Side\n  /** Ticks grow from the rail's edge, or out from its center line. */\n  align?: \"edge\" | \"center\"\n  /** What shows beside the crest. */\n  preview?: \"card\" | \"label\"\n  /** Row pitch along the rail, in pixels. */\n  rowSize?: number\n  /** Resting mark length (or dot diameter), in pixels. */\n  restLength?: number\n  /** Mark length (or dot diameter) at the crest, in pixels. */\n  peakLength?: number\n  /** How far the wave reaches from the pointer, in rows. */\n  radius?: number\n  /** The chapter marked as current. Pass it to control the marker. */\n  currentIndex?: number\n  /** The starting current chapter when uncontrolled. */\n  defaultCurrentIndex?: number\n  /** Fires when a chapter is chosen, with the new current index. */\n  onCurrentIndexChange?: (index: number, chapter: Chapter) => void\n  /** Fires as the hovered or focused chapter changes; `null` on leave. */\n  onActiveChange?: (chapter: Chapter | null, index: number) => void\n  /** Accessible name for the rail. */\n  label?: string\n  /**\n   * Classes for the preview. It's portalled to the body, so it can't be\n   * reached with a selector on the rail.\n   */\n  previewClassName?: string\n}\n\n/**\n * A rail of marks that swell under the pointer the way the macOS Dock does,\n * with a preview of the chapter at the crest. Clicking, Enter or Space makes\n * that chapter current; arrow keys, Home and End move along the rail.\n */\nfunction ChapterScrubber({\n  chapters,\n  orientation = \"vertical\",\n  variant = \"tick\",\n  size = \"default\",\n  side,\n  align = \"edge\",\n  preview = \"card\",\n  rowSize,\n  restLength,\n  peakLength,\n  radius = 4,\n  currentIndex: currentIndexProp,\n  defaultCurrentIndex,\n  onCurrentIndexChange,\n  onActiveChange,\n  label = \"Chapters\",\n  previewClassName,\n  className,\n  style,\n  ref,\n  ...props\n}: ChapterScrubberProps) {\n  const vertical = orientation === \"vertical\"\n  const preset = SIZES[variant][size]\n  // One object across renders, so the memoized marks skip a re-render.\n  const metrics = React.useMemo<Metrics>(\n    () => ({\n      rowSize: rowSize ?? preset.rowSize,\n      rest: restLength ?? preset.rest,\n      peak: peakLength ?? preset.peak,\n      thickness: preset.thickness,\n    }),\n    [rowSize, restLength, peakLength, preset]\n  )\n  const preferredSide: Side = side ?? (vertical ? \"right\" : \"top\")\n\n  const reduceMotion = useReducedMotion()\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  // A caller's ref would otherwise replace the one the rail is measured by.\n  const mergedRootRef = React.useMemo(() => mergeRefs(rootRef, ref), [ref])\n  const railRef = React.useRef<HTMLDivElement>(null)\n  const previewRef = React.useRef<HTMLDivElement>(null)\n  const optionsRef = React.useRef<Array<HTMLButtonElement | null>>([])\n  const baseId = React.useId()\n  const optionId = (index: number) => `${baseId}-option-${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 keeps the wave's shape but drops the springs, so it\n  // appears and follows instantly instead of easing.\n  const pointer = reduceMotion ? rawPointer : springPointer\n  const strength = reduceMotion ? rawStrength : springStrength\n\n  const [uncontrolledCurrent, setUncontrolledCurrent] =\n    React.useState(defaultCurrentIndex)\n  const currentIndex = currentIndexProp ?? uncontrolledCurrent\n\n  const [activeIndex, setActiveIndex] = React.useState(0)\n  const [engaged, setEngaged] = React.useState(false)\n  const [flipped, setFlipped] = React.useState(false)\n  const [previewSize, setPreviewSize] = React.useState(0)\n  // Room across the rail on the chosen side. On a narrow screen neither side\n  // may fit the card, so it takes whichever has more and narrows to fit.\n  const [room, setRoom] = React.useState(Infinity)\n  // Where the rail sits on screen. The preview is portalled to the body so\n  // no clipping ancestor can cut it off, and is placed from this.\n  const [anchor, setAnchor] = React.useState<{\n    rect: DOMRect\n    width: number\n    height: number\n  } | null>(null)\n  // The portal needs a document, which only exists once on the client.\n  const mounted = React.useSyncExternalStore(\n    subscribeToNothing,\n    () => true,\n    () => false\n  )\n  const hoveringRef = React.useRef(false)\n  const focusedRef = React.useRef<number | null>(null)\n  const activeRef = React.useRef(0)\n\n  const last = chapters.length - 1\n  const railLength = chapters.length * metrics.rowSize\n\n  const commitActive = React.useCallback((index: number) => {\n    if (index === activeRef.current) return\n    activeRef.current = index\n    setActiveIndex(index)\n  }, [])\n\n  // Reports changes only: nothing on mount while idle, and nothing when a\n  // re-render leaves the same chapter active.\n  const reportActive = React.useEffectEvent(\n    (chapter: Chapter | null, index: number) => onActiveChange?.(chapter, index)\n  )\n  const reportedRef = React.useRef<{ chapter: Chapter | null; index: number }>({\n    chapter: null,\n    index: -1,\n  })\n  React.useEffect(() => {\n    const chapter = engaged ? (chapters[activeIndex] ?? null) : null\n    const index = engaged ? activeIndex : -1\n    const reported = reportedRef.current\n    if (reported.chapter === chapter && reported.index === index) return\n    reportedRef.current = { chapter, index }\n    reportActive(chapter, index)\n  }, [engaged, activeIndex, chapters])\n\n  const [previewWidth, setPreviewWidth] = React.useState(0)\n  // The preview is clamped to the rail's length, which needs its size along\n  // the rail.\n  React.useLayoutEffect(() => {\n    const node = previewRef.current\n    if (!node) return\n    setPreviewSize(vertical ? node.offsetHeight : node.offsetWidth)\n    setPreviewWidth(node.offsetWidth)\n  }, [activeIndex, vertical, preview, room])\n\n  // Measure the rail, and decide which side the preview opens on, before\n  // paint, so it never shows a frame on the wrong side. While engaged, the\n  // rail is re-measured as the page scrolls or resizes.\n  React.useLayoutEffect(() => {\n    if (!engaged) return\n    const root = rootRef.current\n    const view = root?.ownerDocument.defaultView\n    if (!root || !view) return\n    const measure = () => {\n      const rect = root.getBoundingClientRect()\n      // On touch a tap leaves the rail focused, so the preview would stay\n      // pinned over the page once the rail scrolls away. Let it go instead.\n      if (\n        rect.bottom < 0 ||\n        rect.top > view.innerHeight ||\n        rect.right < 0 ||\n        rect.left > view.innerWidth\n      ) {\n        const focused = root.ownerDocument.activeElement\n        if (focused instanceof HTMLElement && root.contains(focused)) {\n          focused.blur()\n        }\n        focusedRef.current = null\n        hoveringRef.current = false\n        rawStrength.set(0)\n        setEngaged(false)\n        return\n      }\n      setAnchor({ rect, width: view.innerWidth, height: view.innerHeight })\n      const node = previewRef.current\n      const need =\n        (vertical\n          ? preview === \"card\"\n            ? CARD_WIDTH\n            : (node?.scrollWidth ?? LABEL_MAX_WIDTH)\n          : (node?.offsetHeight ?? CARD_FALLBACK_HEIGHT)) +\n        GAP +\n        VIEWPORT_MARGIN\n      const before = vertical ? rect.left : rect.top\n      const after = vertical\n        ? view.innerWidth - rect.right\n        : view.innerHeight - rect.bottom\n      const wantsAfter = preferredSide === \"right\" || preferredSide === \"bottom\"\n      let useAfter = wantsAfter\n      if (useAfter && after < need && before >= need) useAfter = false\n      if (!useAfter && before < need && after >= need) useAfter = true\n      if (before < need && after < need) useAfter = after >= before\n      setFlipped(useAfter !== wantsAfter)\n      if (vertical) {\n        setRoom((useAfter ? after : before) - GAP - VIEWPORT_MARGIN)\n      }\n    }\n    measure()\n    view.addEventListener(\"scroll\", measure, true)\n    view.addEventListener(\"resize\", measure)\n    return () => {\n      view.removeEventListener(\"scroll\", measure, true)\n      view.removeEventListener(\"resize\", measure)\n    }\n  }, [engaged, preferredSide, vertical, preview, rawStrength])\n\n  const opposite: Record<Side, Side> = {\n    left: \"right\",\n    right: \"left\",\n    top: \"bottom\",\n    bottom: \"top\",\n  }\n  const resolvedSide = flipped ? opposite[preferredSide] : preferredSide\n\n  const previewOffset = useTransform(pointer, (p) => {\n    const half = previewSize / 2\n    const center = clamp(\n      (p + 0.5) * metrics.rowSize,\n      half,\n      Math.max(half, railLength - half)\n    )\n    return center - half\n  })\n  const previewScale = useTransform(strength, [0, 1], [0.97, 1])\n  const drift = resolvedSide === \"right\" || resolvedSide === \"bottom\" ? -6 : 6\n  const previewShift = useTransform(strength, [0, 1], [drift, 0])\n  // A horizontal rail's preview runs along it from the rail's start, so near\n  // either screen edge it is nudged back inside.\n  const alongShift = useTransform(previewOffset, (offset) => {\n    if (vertical || !anchor) return offset\n    const start = anchor.rect.left + offset\n    const end = start + previewWidth\n    if (end > anchor.width - VIEWPORT_MARGIN) {\n      return offset - (end - (anchor.width - VIEWPORT_MARGIN))\n    }\n    if (start < VIEWPORT_MARGIN) return offset + (VIEWPORT_MARGIN - start)\n    return offset\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 release = () => {\n    rawStrength.set(0)\n    setEngaged(false)\n  }\n\n  const choose = (index: number) => {\n    const chapter = chapters[index]\n    if (!chapter) return\n    if (currentIndexProp === undefined) setUncontrolledCurrent(index)\n    onCurrentIndexChange?.(index, chapter)\n  }\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rail = railRef.current\n    if (!rail) return\n    const rect = rail.getBoundingClientRect()\n    const offset = vertical\n      ? event.clientY - rect.top\n      : event.clientX - rect.left\n    const row = offset / metrics.rowSize - 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) rawPointer.set(focusedRef.current)\n    else release()\n  }\n\n  const handleBlur = (event: React.FocusEvent<HTMLDivElement>) => {\n    if (event.currentTarget.contains(event.relatedTarget as Node | null)) return\n    focusedRef.current = null\n    if (!hoveringRef.current) release()\n  }\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\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    optionsRef.current[next]?.focus()\n  }\n\n  // Exactly one option is tabbable at a time.\n  const rovingIndex = engaged ? activeIndex : (currentIndex ?? 0)\n  const crossSize = Math.max(metrics.peak, 1)\n\n  // Ticks sit against the edge the preview opens from, so they grow toward\n  // it; `center` grows both ways from the middle line.\n  const markAlign =\n    align === \"center\" || variant === \"dot\"\n      ? \"center\"\n      : resolvedSide === \"right\" || resolvedSide === \"bottom\"\n        ? \"start\"\n        : \"end\"\n\n  const chapter = chapters[activeIndex]\n  const placement = anchor\n    ? {\n        right: { top: anchor.rect.top, left: anchor.rect.right + GAP },\n        left: {\n          top: anchor.rect.top,\n          right: anchor.width - anchor.rect.left + GAP,\n        },\n        bottom: { left: anchor.rect.left, top: anchor.rect.bottom + GAP },\n        top: {\n          left: anchor.rect.left,\n          bottom: anchor.height - anchor.rect.top + GAP,\n        },\n      }[resolvedSide]\n    : { visibility: \"hidden\" as const }\n\n  return (\n    <div\n      ref={mergedRootRef}\n      data-slot=\"chapter-scrubber\"\n      data-orientation={orientation}\n      data-variant={variant}\n      style={{\n        ...(vertical ? { width: crossSize } : { height: crossSize }),\n        ...style,\n      }}\n      className={cn(\"relative shrink-0\", className)}\n      {...props}\n    >\n      <div\n        ref={railRef}\n        role=\"listbox\"\n        aria-label={label}\n        aria-orientation={orientation}\n        className={cn(\"flex\", vertical ? \"w-full flex-col\" : \"h-full flex-row\")}\n        onPointerMove={handlePointerMove}\n        onPointerLeave={handlePointerLeave}\n        onKeyDown={handleKeyDown}\n        onBlur={handleBlur}\n      >\n        {chapters.map((item, index) => {\n          const isCurrent = index === currentIndex\n          const description =\n            typeof item.description === \"string\" ? `. ${item.description}` : \"\"\n          return (\n            <button\n              ref={(node) => {\n                optionsRef.current[index] = node\n              }}\n              key={item.id}\n              id={optionId(index)}\n              data-slot=\"chapter-scrubber-item\"\n              type=\"button\"\n              role=\"option\"\n              aria-selected={isCurrent}\n              aria-label={`${item.title}${description}`}\n              tabIndex={index === rovingIndex ? 0 : -1}\n              onFocus={() => {\n                focusedRef.current = index\n                engageAt(index, index)\n              }}\n              onClick={() => choose(index)}\n              style={\n                vertical\n                  ? { height: metrics.rowSize }\n                  : { width: metrics.rowSize }\n              }\n              className={cn(\n                \"flex shrink-0 rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring-subtle\",\n                vertical ? \"w-full items-center\" : \"h-full justify-center\",\n                vertical\n                  ? {\n                      start: \"justify-start\",\n                      center: \"justify-center\",\n                      end: \"justify-end\",\n                    }[markAlign]\n                  : {\n                      start: \"items-start\",\n                      center: \"items-center\",\n                      end: \"items-end\",\n                    }[markAlign]\n              )}\n            >\n              <Mark\n                index={index}\n                pointer={pointer}\n                strength={strength}\n                radius={radius}\n                metrics={metrics}\n                variant={variant}\n                orientation={orientation}\n                isCurrent={isCurrent}\n              />\n            </button>\n          )\n        })}\n      </div>\n\n      {chapter && mounted\n        ? createPortal(\n            <motion.div\n              ref={previewRef}\n              aria-hidden=\"true\"\n              data-slot=\"chapter-scrubber-preview\"\n              style={{\n                // Fixed to the rail's place on screen; the springs move it along\n                // the rail and ease it in, as a transform on top.\n                ...(vertical\n                  ? { y: previewOffset, x: previewShift }\n                  : { x: alongShift, y: previewShift }),\n                scale: previewScale,\n                opacity: strength,\n                ...(preview === \"card\"\n                  ? { width: Math.min(CARD_WIDTH, room) }\n                  : { maxWidth: Math.min(LABEL_MAX_WIDTH, room) }),\n                ...placement,\n              }}\n              className={cn(\n                \"pointer-events-none fixed z-50\",\n                {\n                  right: \"origin-left\",\n                  left: \"origin-right\",\n                  bottom: \"origin-top\",\n                  top: \"origin-bottom\",\n                }[resolvedSide],\n                preview === \"card\"\n                  ? \"rounded-xl border border-border bg-popover p-3 text-popover-foreground shadow-md\"\n                  : \"w-max rounded-md border border-border bg-popover px-2 py-1 text-popover-foreground shadow-sm\",\n                previewClassName\n              )}\n            >\n              {preview === \"card\" ? (\n                <>\n                  {/* Title and time share a row, like a chapter list, so the\n                  card leads with what the chapter is. */}\n                  <div className=\"flex items-baseline gap-3\">\n                    <div className=\"min-w-0 flex-1 truncate text-sm leading-5 font-medium tracking-[-0.01em]\">\n                      {chapter.title}\n                    </div>\n                    {chapter.meta ? (\n                      <span className=\"shrink-0 font-mono text-xs text-muted-foreground tabular-nums\">\n                        {chapter.meta}\n                      </span>\n                    ) : null}\n                  </div>\n                  {chapter.description ? (\n                    <p className=\"mt-1 line-clamp-3 text-[13px] leading-[18px] text-pretty text-muted-foreground\">\n                      {chapter.description}\n                    </p>\n                  ) : null}\n                </>\n              ) : (\n                <div className=\"flex items-baseline gap-2 text-sm whitespace-nowrap\">\n                  {chapter.meta ? (\n                    <span className=\"font-mono text-xs text-muted-foreground tabular-nums\">\n                      {chapter.meta}\n                    </span>\n                  ) : null}\n                  <span className=\"truncate font-medium text-foreground\">\n                    {chapter.title}\n                  </span>\n                </div>\n              )}\n            </motion.div>,\n            document.body\n          )\n        : null}\n    </div>\n  )\n}\n\nexport { ChapterScrubber, type Chapter, type ChapterScrubberProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/lib/merge-refs.ts",
      "content": "import * as React from \"react\"\n\n/**\n * One callback ref that sets every ref given, so a part can keep its own\n * ref on an element and still hand the element to a caller's `ref`. Undo\n * runs through React 19's ref cleanup, which calls a function ref with\n * null and empties an object ref.\n */\nexport function mergeRefs<T>(\n  ...refs: (React.Ref<T> | undefined)[]\n): React.RefCallback<T> {\n  return (node) => {\n    const cleanups = refs.map((ref) => {\n      if (typeof ref === \"function\") {\n        const cleanup = ref(node)\n        return typeof cleanup === \"function\" ? cleanup : () => ref(null)\n      }\n      if (ref) {\n        ref.current = node\n        return () => {\n          ref.current = null\n        }\n      }\n      return undefined\n    })\n    return () => {\n      for (const cleanup of cleanups) cleanup?.()\n    }\n  }\n}\n",
      "type": "registry:lib"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ring-subtle": "var(--ring-subtle)"
    },
    "light": {
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-400) 50%, transparent)"
    },
    "dark": {
      "ring-subtle": "color-mix(in oklch, var(--color-neutral-500) 50%, transparent)"
    }
  },
  "meta": {
    "tier": "special-components",
    "group": "Navigation",
    "docs": "https://fibo.toribryan.com/?path=/docs/special-components-chapter-scrubber--docs"
  },
  "docs": "Docs and live examples: https://fibo.toribryan.com/?path=/docs/special-components-chapter-scrubber--docs",
  "categories": [
    "special-components",
    "navigation"
  ],
  "type": "registry:ui"
}