{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toc-minimap-mercury",
  "title": "TOC Minimap Mercury",
  "description": "TOC minimap where scroll progress is a column of liquid mercury: a gooey SVG blob climbs the rail, absorbs section ticks, teardrops under fast scroll, and bulges toward hovered ticks.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/toc-minimap-mercury/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\n\ntype MinimapSection = { id: string; label: string };\n\nconst RAIL_W = 48;\nconst CX = RAIL_W / 2;\nconst PAD = 16;\n\n/** Evenly spaced tick centers along a rail of height H. */\nfunction tickPositions(n: number, H: number): number[] {\n  if (n <= 1) return [H / 2];\n  return Array.from({ length: n }, (_, i) => PAD + (i * (H - 2 * PAD)) / (n - 1));\n}\n\n/** Piecewise-linear map: document read-line position → rail y via section offsets. */\nfunction mapTarget(read: number, tops: number[], ticks: number[]): number {\n  const n = ticks.length;\n  const first = ticks[0] ?? 0;\n  const last = ticks[n - 1] ?? first;\n  if (n <= 1 || read <= (tops[0] ?? 0)) return first;\n  if (read >= (tops[n - 1] ?? 0)) return last;\n  let i = 0;\n  while (i < n - 2 && read >= (tops[i + 1] ?? Infinity)) i++;\n  const a = tops[i] ?? 0;\n  const b = tops[i + 1] ?? a + 1;\n  const t = (read - a) / Math.max(1, b - a);\n  return (ticks[i] ?? 0) + t * ((ticks[i + 1] ?? 0) - (ticks[i] ?? 0));\n}\n\n// Liquid-mercury TOC minimap: scroll progress is a gooey column that climbs a\n// fixed rail, absorbing section ticks as it reaches them. Spring-integrated on\n// a direct-DOM rAF loop — no React state on the hot path, loop sleeps when\n// settled. Static 2px line + dots under prefers-reduced-motion.\nexport function MercuryMinimap({\n  sections,\n  selector = \"section[id]\",\n  offset = 0.35,\n  stiffness = 120,\n  damping = 20,\n  className = \"\",\n}: {\n  /** explicit section list; omitted → auto-discovered via `selector` (label from data-minimap-label or id) */\n  sections?: MinimapSection[];\n  /** querySelectorAll used when `sections` is omitted */\n  selector?: string;\n  /** read-line as a fraction of viewport height — a section is \"reached\" when its top passes this line */\n  offset?: number;\n  /** spring stiffness for the blob (px/s² per px of error) */\n  stiffness?: number;\n  /** spring damping (slightly under critical → settle wobble) */\n  damping?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const [items, setItems] = useState<MinimapSection[] | null>(null);\n  const [H, setH] = useState(0);\n  const [reduced, setReduced] = useState(false);\n\n  const svgRef = useRef<SVGSVGElement | null>(null);\n  const wrapRef = useRef<HTMLElement | null>(null);\n  const colRef = useRef<SVGRectElement | null>(null);\n  const blobRef = useRef<SVGCircleElement | null>(null);\n  const helperRef = useRef<SVGCircleElement | null>(null);\n  const lineRef = useRef<SVGRectElement | null>(null);\n  const tickRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const gooTickRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const dotRefs = useRef<(SVGCircleElement | null)[]>([]);\n  const labelRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  // lets the label buttons drive the hover bulge without React state\n  const hoverCtl = useRef<(i: number) => void>(() => {});\n\n  const rawId = useId();\n  const fid = `goo-${rawId.replace(/[^a-zA-Z0-9-]/g, \"\")}`;\n\n  // discovery + sizing (rare, state is fine here)\n  useEffect(() => {\n    setReduced(window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches);\n    const discover = () =>\n      sections ??\n      Array.from(document.querySelectorAll<HTMLElement>(selector)).map((el) => ({\n        id: el.id,\n        label: el.dataset.minimapLabel ?? el.id.toUpperCase(),\n      }));\n    // guard against unstable `sections` identity (e.g. an inline array literal\n    // passed by the consumer): only commit a new `items` reference when the\n    // discovered content actually changed, otherwise the mercury-loop effect\n    // below (keyed on `items`) would tear down and rebuild every render.\n    setItems((prev) => {\n      const next = discover();\n      if (\n        prev &&\n        prev.length === next.length &&\n        prev.every((p, i) => p.id === next[i]?.id && p.label === next[i]?.label)\n      ) {\n        return prev;\n      }\n      return next;\n    });\n    const measure = () => setH(Math.round(window.innerHeight * 0.6));\n    measure();\n    window.addEventListener(\"resize\", measure);\n    return () => window.removeEventListener(\"resize\", measure);\n  }, [sections, selector]);\n\n  // mercury loop — direct setAttribute writes only, sleeps when settled\n  useEffect(() => {\n    if (!items || items.length === 0 || !H || reduced) return;\n    const n = items.length;\n    const ticks = tickPositions(n, H);\n    const railTop = ticks[0] ?? PAD;\n    const els = items.map((s) => document.getElementById(s.id));\n    let tops = items.map(() => 0);\n    let vh = window.innerHeight;\n    const measure = () => {\n      vh = window.innerHeight;\n      tops = els.map((el) => (el ? el.getBoundingClientRect().top + window.scrollY : 0));\n    };\n    measure();\n\n    let scroll = window.scrollY;\n    let y = mapTarget(scroll + vh * offset, tops, ticks);\n    let v = 0;\n    let helperR = 0;\n    let hover = -1;\n    let active = -2;\n    let raf = 0;\n    let last = 0;\n\n    const setActive = (idx: number) => {\n      if (idx === active) return;\n      active = idx;\n      labelRefs.current.forEach((el, i) => {\n        if (!el) return;\n        el.classList.toggle(\"text-foreground\", i === idx);\n        el.classList.toggle(\"text-ns-muted\", i !== idx);\n      });\n    };\n\n    const frame = (now: number) => {\n      const dt = Math.min(Math.max((now - last) / 1000, 0.001), 1 / 30);\n      last = now;\n      const target = mapTarget(scroll + vh * offset, tops, ticks);\n\n      // semi-implicit Euler spring — underdamped, so the settle wobble is free\n      v += (stiffness * (target - y) - damping * v) * dt;\n      y += v * dt;\n\n      // volume-preserving droplet stretch, oriented along travel. A small\n      // constant bias keeps the blob a teardrop (not a plain circle) even at\n      // rest, so the liquid-mercury mechanic reads in a still frame too.\n      const sY = 1.06 + Math.min(Math.abs(v) * 0.004, 0.74);\n      const sX = 1 / Math.sqrt(sY);\n      const lead = Math.sign(v) * (sY - 1) * 4;\n      blobRef.current?.setAttribute(\n        \"transform\",\n        `translate(${CX} ${y + lead}) scale(${sX} ${sY})`\n      );\n      colRef.current?.setAttribute(\"height\", `${Math.max(0, y - railTop)}`);\n\n      // absorption: crossing a tick pulls it into the goo group; the filter\n      // renders the merge, scrolling back up snaps the neck and releases it\n      let reach = 0;\n      for (let i = 0; i < n; i++) {\n        const inGoo = y >= (ticks[i] ?? 0) - 6;\n        if (inGoo) reach = i;\n        const goo = gooTickRefs.current[i];\n        if (goo) goo.style.visibility = inGoo ? \"visible\" : \"hidden\";\n        const outline = tickRefs.current[i];\n        if (outline) outline.style.opacity = inGoo ? \"0\" : \"1\";\n      }\n      setActive(reach);\n\n      // hover bulge: helper circle grows 0→5 at 60% of the way toward the tick\n      const hTarget = hover >= 0 ? 5 : 0;\n      helperR += (hTarget - helperR) * Math.min(1, dt * 16);\n      const helper = helperRef.current;\n      if (helper) {\n        if (hover >= 0) {\n          const ty = ticks[hover] ?? railTop;\n          const colY = Math.min(Math.max(ty, railTop), y);\n          helper.setAttribute(\"cy\", `${colY + 0.6 * (ty - colY)}`);\n        }\n        helper.setAttribute(\"r\", `${Math.max(0, helperR)}`);\n      }\n\n      const settled =\n        Math.abs(v) < 2 && Math.abs(target - y) < 0.5 && Math.abs(hTarget - helperR) < 0.05;\n      raf = settled ? 0 : requestAnimationFrame(frame);\n    };\n    const wake = () => {\n      if (!raf) {\n        last = performance.now();\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    const onScroll = () => {\n      scroll = window.scrollY;\n      wake();\n    };\n    const onResize = () => {\n      measure();\n      scroll = window.scrollY;\n      wake();\n    };\n    const onMove = (e: PointerEvent) => {\n      const svg = svgRef.current;\n      if (!svg) return;\n      const py = e.clientY - svg.getBoundingClientRect().top;\n      let best = -1;\n      let bd = 24;\n      for (let i = 0; i < n; i++) {\n        const d = Math.abs(py - (ticks[i] ?? 0));\n        if (d < bd) {\n          bd = d;\n          best = i;\n        }\n      }\n      if (best !== hover) {\n        hover = best;\n        wake();\n      }\n    };\n    const onLeave = () => {\n      hover = -1;\n      wake();\n    };\n    hoverCtl.current = (i: number) => {\n      hover = i;\n      wake();\n    };\n\n    const wrap = wrapRef.current;\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onResize);\n    wrap?.addEventListener(\"pointermove\", onMove);\n    wrap?.addEventListener(\"pointerleave\", onLeave);\n    wake();\n    return () => {\n      cancelAnimationFrame(raf);\n      hoverCtl.current = () => {};\n      window.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onResize);\n      wrap?.removeEventListener(\"pointermove\", onMove);\n      wrap?.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [items, H, reduced, offset, stiffness, damping]);\n\n  // reduced motion: plain filled line + dots, instant states, no rAF\n  useEffect(() => {\n    if (!items || items.length === 0 || !H || !reduced) return;\n    const n = items.length;\n    const ticks = tickPositions(n, H);\n    const railTop = ticks[0] ?? PAD;\n    const els = items.map((s) => document.getElementById(s.id));\n    let tops = items.map(() => 0);\n    const measure = () => {\n      tops = els.map((el) => (el ? el.getBoundingClientRect().top + window.scrollY : 0));\n    };\n    measure();\n\n    const apply = () => {\n      const t = mapTarget(window.scrollY + window.innerHeight * offset, tops, ticks);\n      lineRef.current?.setAttribute(\"height\", `${Math.max(0, t - railTop)}`);\n      let reach = 0;\n      for (let i = 0; i < n; i++) {\n        const on = t >= (ticks[i] ?? 0) - 6;\n        if (on) reach = i;\n        const dot = dotRefs.current[i];\n        if (dot) {\n          dot.setAttribute(\"fill\", on ? \"var(--foreground)\" : \"none\");\n          dot.setAttribute(\n            \"stroke\",\n            on ? \"var(--foreground)\" : \"var(--ns-muted)\"\n          );\n        }\n      }\n      labelRefs.current.forEach((el, i) => {\n        if (!el) return;\n        el.classList.toggle(\"text-foreground\", i === reach);\n        el.classList.toggle(\"text-ns-muted\", i !== reach);\n      });\n    };\n    const onResize = () => {\n      measure();\n      apply();\n    };\n    apply();\n    window.addEventListener(\"scroll\", apply, { passive: true });\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      window.removeEventListener(\"scroll\", apply);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [items, H, reduced, offset]);\n\n  if (!items || items.length === 0 || !H) return null;\n\n  const ticks = tickPositions(items.length, H);\n  const railTop = ticks[0] ?? PAD;\n\n  return (\n    <nav\n      ref={wrapRef}\n      aria-label=\"Section minimap\"\n      className={`group fixed right-4 top-1/2 z-40 -translate-y-1/2 ${className}`}\n    >\n      {items.map((s, i) => (\n        <button\n          key={s.id}\n          type=\"button\"\n          aria-label={`Jump to ${s.label}`}\n          onClick={() =>\n            document\n              .getElementById(s.id)\n              ?.scrollIntoView({ behavior: reduced ? \"auto\" : \"smooth\" })\n          }\n          onFocus={() => hoverCtl.current(i)}\n          onBlur={() => hoverCtl.current(-1)}\n          className=\"absolute right-0 flex h-8 -translate-y-1/2 cursor-pointer items-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n          style={{ top: ticks[i] ?? 0, paddingRight: RAIL_W }}\n        >\n          <span\n            ref={(el) => {\n              labelRefs.current[i] = el;\n            }}\n            className=\"-translate-x-2 whitespace-nowrap pr-2 font-mono text-xs text-ns-muted opacity-0 transition-[transform,opacity] duration-150 ease-out group-focus-within:translate-x-0 group-focus-within:opacity-100 group-hover:translate-x-0 group-hover:opacity-100 motion-reduce:transition-none\"\n          >\n            {s.label}\n          </span>\n        </button>\n      ))}\n\n      {reduced ? (\n        <svg width={RAIL_W} height={H} viewBox={`0 0 ${RAIL_W} ${H}`} aria-hidden className=\"block\">\n          <rect\n            ref={lineRef}\n            x={CX - 1}\n            y={railTop}\n            width={2}\n            height={0}\n            fill=\"var(--foreground)\"\n          />\n          {items.map((s, i) => (\n            <circle\n              key={s.id}\n              ref={(el) => {\n                dotRefs.current[i] = el;\n              }}\n              cx={CX}\n              cy={ticks[i] ?? 0}\n              r={4}\n              fill=\"none\"\n              stroke=\"var(--ns-muted)\"\n              strokeWidth={1.5}\n            />\n          ))}\n        </svg>\n      ) : (\n        <svg\n          ref={svgRef}\n          width={RAIL_W}\n          height={H}\n          viewBox={`0 0 ${RAIL_W} ${H}`}\n          aria-hidden\n          className=\"block overflow-visible\"\n        >\n          <defs>\n            <filter\n              id={fid}\n              x=\"-100%\"\n              y=\"-20%\"\n              width=\"300%\"\n              height=\"140%\"\n              colorInterpolationFilters=\"sRGB\"\n            >\n              <feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"4\" result=\"blur\" />\n              <feColorMatrix\n                in=\"blur\"\n                type=\"matrix\"\n                values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 19 -9\"\n              />\n            </filter>\n          </defs>\n          {/* outline ticks — outside the goo so they stay crisp until absorbed */}\n          {items.map((s, i) => (\n            <circle\n              key={s.id}\n              ref={(el) => {\n                tickRefs.current[i] = el;\n              }}\n              cx={CX}\n              cy={ticks[i] ?? 0}\n              r={4}\n              fill=\"none\"\n              stroke=\"var(--ns-muted)\"\n              strokeWidth={1.5}\n              style={{ transition: \"opacity 150ms ease-out\" }}\n            />\n          ))}\n          {/* liquid layer — everything in here gooey-merges */}\n          <g filter={`url(#${fid})`} fill=\"var(--foreground)\">\n            {/* 8px wide: survives the blur-4 → ×19−9 alpha threshold (4px would vanish) */}\n            <rect ref={colRef} x={CX - 4} y={railTop} width={8} height={0} rx={4} />\n            {items.map((s, i) => (\n              <circle\n                key={s.id}\n                ref={(el) => {\n                  gooTickRefs.current[i] = el;\n                }}\n                cx={CX}\n                cy={ticks[i] ?? 0}\n                r={6}\n                style={{ visibility: \"hidden\" }}\n              />\n            ))}\n            <circle ref={helperRef} cx={CX} cy={railTop} r={0} />\n            <circle ref={blobRef} r={7} transform={`translate(${CX} ${railTop})`} />\n          </g>\n        </svg>\n      )}\n    </nav>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/toc-minimap-mercury.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "nav",
      "scroll",
      "svg",
      "goo",
      "toc",
      "cursor",
      "micro-interaction"
    ],
    "instruction": "Build a fixed-right table-of-contents minimap where scroll progress renders as liquid mercury inside a 48px-wide, 60vh-tall SVG rail. Use one goo filter (feGaussianBlur stdDeviation 4 into an feColorMatrix with alpha row 0 0 0 19 -9) over a liquid group filled with the foreground token: a 4px rounded-rect column from rail top to the blob, a leading r=7 blob circle, and per-section r=4 circles that start hidden; outside the filter, draw crisp unfilled r=4 ticks stroked with the muted token at each section's evenly mapped y. Map window scroll through the sections' document offsets piecewise-linearly to a target y, and drive the blob with a semi-implicit Euler spring (stiffness 120, damping 20, slightly underdamped so stops land with a droplet wobble) integrated in a requestAnimationFrame loop that writes only via setAttribute, reads scroll from a ref set by a passive listener, and sleeps when velocity and error settle. Stretch the blob volume-preservingly by velocity — scaleY = 1.06 + clamp(|v|*0.004, 0, 0.74), scaleX = 1/sqrt(scaleY) — shifted a few px toward the travel direction, with a small constant bias baked into the 1.06 floor so the blob still reads as a teardrop (not a plain circle) at rest, not just while moving; fast scroll further stretches the leading edge. When the blob passes within 6px of a tick, reveal that tick's hidden circle inside the goo group (the filter renders the merge) and fade the outline tick; scrolling back up re-hides it so the goo neck snaps and releases the dot. On pointer within 24px of a tick, grow a helper circle in the goo group from r 0 to 5 positioned 60% of the way from the column toward the tick so the liquid bulges at the cursor; clicking a tick smooth-scrolls its section into view. Geist Mono text-xs labels slide in from translateX(-8px) to 0 over 150ms ease-out on rail hover, muted by default and foreground when active. Under prefers-reduced-motion swap the whole goo layer for a plain 2px filled line plus dots with instant active states and no animation loop. Auto-discover section[id] elements when no sections prop is given."
  },
  "type": "registry:ui"
}