{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-caliper",
  "title": "Scroll Caliper",
  "description": "A vernier caliper pinned to a scroll container's edge whose spring-damped jaws close over the active section's extent while a mono readout ticks px and percent, tick marks streaking with velocity-scaled motion blur.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/scroll-caliper/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, type ReactNode } from \"react\";\n\nconst W = 48; // instrument width in px\n\n// ---------------------------------------------------------------------------\n// damped spring integrator — k in s^-2, zeta dimensionless (1 = critical)\n// ---------------------------------------------------------------------------\ntype Spring = { x: number; v: number; t: number };\nfunction stepSpring(s: Spring, k: number, zeta: number, dt: number) {\n  const c = 2 * zeta * Math.sqrt(k);\n  s.v += (k * (s.t - s.x) - c * s.v) * dt;\n  s.x += s.v * dt;\n}\nfunction isSettled(s: Spring) {\n  return Math.abs(s.x - s.t) < 0.05 && Math.abs(s.v) < 0.05;\n}\n\n// bare-usage fallback content: three short measured sections\nfunction FallbackSections() {\n  const blocks: [string, string][] = [\n    [\n      \"Datum\",\n      \"Every measurement starts from a reference surface. The caliper treats the top of each section as its datum and closes the opposing jaw on the far edge.\",\n    ],\n    [\n      \"Extent\",\n      \"The jaw opening reads the section's visible span in pixels. Scroll and the blades chase the new edges with near-critical spring damping, a hair of lag by design.\",\n    ],\n    [\n      \"Runout\",\n      \"Stop scrolling and the needle over-settles with a visible wobble before locking, the way a real dial indicator bleeds off its last few microns of momentum.\",\n    ],\n  ];\n  return (\n    <div className=\"space-y-12 p-8\">\n      {blocks.map(([title, copy]) => (\n        <section key={title} data-section className=\"space-y-3\">\n          <h3 className=\"text-sm font-semibold text-foreground\">{title}</h3>\n          <p className=\"text-sm leading-relaxed text-ns-muted\">{copy}</p>\n          <p className=\"text-sm leading-relaxed text-ns-muted\">{copy}</p>\n        </section>\n      ))}\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// ScrollCaliper — a vernier caliper pinned to the right edge of a bounded\n// scroll container. Its jaws close over the active [data-section]'s vertical\n// extent (spring k=120 s^-2, zeta=0.9), a font-mono readout ticks px/percent\n// on a softer spring (k=90, zeta=0.55) so it wobbles when scrolling stops,\n// and the tick scale streaks with velocity-scaled motion blur. SVG + direct-\n// DOM rAF, refs only; the loop sleeps 150ms after the last scroll once every\n// spring settles. All ink is currentColor via token classes (text-border /\n// text-foreground / text-ns-accent), so theme flips restyle the SVG live with\n// no numeric getComputedStyle reads to re-derive.\n// ---------------------------------------------------------------------------\nexport function ScrollCaliper({\n  children,\n  sectionSelector = \"[data-section]\",\n  jawStiffness = 120,\n  jawDamping = 0.9,\n  needleStiffness = 90,\n  needleDamping = 0.55,\n  className = \"h-[480px]\",\n}: {\n  /** scrollable content; sections are matched via sectionSelector */\n  children?: ReactNode;\n  /** selector for measurable sections inside the scroller */\n  sectionSelector?: string;\n  /** jaw spring stiffness in s^-2 (near-critical chase) */\n  jawStiffness?: number;\n  /** jaw damping ratio (1 = critical) */\n  jawDamping?: number;\n  /** readout needle stiffness in s^-2 (softer, visible over-settle) */\n  needleStiffness?: number;\n  /** readout needle damping ratio */\n  needleDamping?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const scrollerRef = useRef<HTMLDivElement>(null);\n  const svgRef = useRef<SVGSVGElement>(null);\n  const tickRef = useRef<SVGGElement>(null);\n  const minorRef = useRef<SVGPathElement>(null);\n  const majorRef = useRef<SVGPathElement>(null);\n  const echoRef = useRef<SVGGElement>(null);\n  const echoMinorRef = useRef<SVGPathElement>(null);\n  const echoMajorRef = useRef<SVGPathElement>(null);\n  const spineRef = useRef<SVGPathElement>(null);\n  const regionRef = useRef<SVGRectElement>(null);\n  const topJawRef = useRef<SVGGElement>(null);\n  const botJawRef = useRef<SVGGElement>(null);\n  const markerRef = useRef<SVGGElement>(null);\n  const chipLabelRef = useRef<HTMLDivElement>(null);\n  const chipPxRef = useRef<HTMLSpanElement>(null);\n  const chipPctRef = useRef<HTMLSpanElement>(null);\n  const liveRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const scroller = scrollerRef.current;\n    const svg = svgRef.current;\n    const tickG = tickRef.current;\n    const minorPath = minorRef.current;\n    const majorPath = majorRef.current;\n    const echoG = echoRef.current;\n    const echoMinor = echoMinorRef.current;\n    const echoMajor = echoMajorRef.current;\n    const spine = spineRef.current;\n    const region = regionRef.current;\n    const topJaw = topJawRef.current;\n    const botJaw = botJawRef.current;\n    const marker = markerRef.current;\n    const chipLabel = chipLabelRef.current;\n    const chipPx = chipPxRef.current;\n    const chipPct = chipPctRef.current;\n    const live = liveRef.current;\n    if (\n      !scroller || !svg || !tickG || !minorPath || !majorPath || !echoG ||\n      !echoMinor || !echoMajor || !spine || !region || !topJaw || !botJaw ||\n      !marker || !chipLabel || !chipPx || !chipPct || !live\n    )\n      return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    const sections = Array.from(\n      scroller.querySelectorAll<HTMLElement>(sectionSelector)\n    );\n    const total = sections.length;\n\n    // hot-path state — closure locals only, never React state\n    const jawTop: Spring = { x: 0, v: 0, t: 0 };\n    const jawBot: Spring = { x: 0, v: 0, t: 0 };\n    const needle: Spring = { x: 0, v: 0, t: 0 }; // percent through section\n    let H = 0;\n    let raf = 0;\n    let running = false;\n    let last = 0;\n    let primed = false;\n    let lastScrollT = 0; // performance.now() of last scroll event\n    let prevScroll = scroller.scrollTop;\n    let vSmooth = 0; // px/s, exponentially smoothed (alpha 0.2)\n    let activeIdx = -1;\n    let prevPxText = \"\";\n    let prevPctText = \"\";\n    let lastAnnounce = 0;\n    let flashTimer: ReturnType<typeof setTimeout> | undefined;\n    let announceTimer: ReturnType<typeof setTimeout> | undefined;\n    const visiblePx = new Map<Element, number>();\n\n    // tick scale: minor every 2px, major every 8px, long major every 40px —\n    // rebuilt on resize only; per-frame motion is a translate of the group\n    const rebuild = () => {\n      H = scroller.clientHeight;\n      if (H <= 0) return; // zero-size container guard\n      svg.setAttribute(\"viewBox\", `0 0 ${W} ${H}`);\n      let minor = \"\";\n      let major = \"\";\n      for (let y = -8; y <= H + 8; y += 2) {\n        if (y % 40 === 0) major += `M${W - 16} ${y}h16`;\n        else if (y % 8 === 0) major += `M${W - 10} ${y}h10`;\n        else minor += `M${W - 5} ${y}h5`;\n      }\n      minorPath.setAttribute(\"d\", minor);\n      majorPath.setAttribute(\"d\", major);\n      echoMinor.setAttribute(\"d\", minor);\n      echoMajor.setAttribute(\"d\", major);\n      spine.setAttribute(\"d\", `M${W - 0.75} 0V${H}`);\n    };\n    rebuild();\n\n    // map the active section's extent to container-space jaw targets;\n    // needle target = percent of the section swept past the container center\n    const measure = () => {\n      if (H <= 0) return;\n      const cRect = scroller.getBoundingClientRect();\n      const el = activeIdx >= 0 ? sections[activeIdx] : undefined;\n      if (el) {\n        const r = el.getBoundingClientRect();\n        jawTop.t = Math.min(H, Math.max(0, r.top - cRect.top));\n        jawBot.t = Math.min(H, Math.max(0, r.bottom - cRect.top));\n        // zero-height section guard: never divide by a degenerate extent\n        needle.t =\n          r.height > 0.5\n            ? Math.min(1, Math.max(0, (cRect.top + H / 2 - r.top) / r.height)) *\n              100\n            : 0;\n      } else {\n        jawTop.t = 0;\n        jawBot.t = H;\n        const range = scroller.scrollHeight - H;\n        needle.t = range > 0 ? (scroller.scrollTop / range) * 100 : 0;\n      }\n    };\n\n    const loop = (now: number) => {\n      const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);\n      last = now;\n      const sc = scroller.scrollTop;\n      vSmooth += 0.2 * ((sc - prevScroll) / dt - vSmooth);\n      prevScroll = sc;\n\n      measure();\n      if (reduced || !primed) {\n        // reduced motion snaps every frame; first frame snaps to avoid a\n        // mount sweep from zero\n        jawTop.x = jawTop.t;\n        jawBot.x = jawBot.t;\n        needle.x = needle.t;\n        jawTop.v = jawBot.v = needle.v = 0;\n        primed = true;\n      } else {\n        stepSpring(jawTop, jawStiffness, jawDamping, dt);\n        stepSpring(jawBot, jawStiffness, jawDamping, dt);\n        stepSpring(needle, needleStiffness, needleDamping, dt);\n      }\n\n      // writes — direct DOM, no React\n      const phase = -(sc % 8);\n      tickG.style.transform = `translateY(${phase.toFixed(2)}px)`;\n      topJaw.style.transform = `translateY(${jawTop.x.toFixed(2)}px)`;\n      botJaw.style.transform = `translateY(${jawBot.x.toFixed(2)}px)`;\n      const gap = Math.max(0, jawBot.x - jawTop.x);\n      region.setAttribute(\"y\", jawTop.x.toFixed(2));\n      region.setAttribute(\"height\", gap.toFixed(2));\n      const pct = Math.min(100, Math.max(0, needle.x));\n      marker.style.transform = `translateY(${(jawTop.x + (pct / 100) * gap).toFixed(2)}px)`;\n\n      // motion-blur echo: duplicated tick layer trailing the scroll direction\n      if (!reduced) {\n        const speed = Math.abs(vSmooth);\n        const o = Math.min(0.6, speed / 3000);\n        const len = Math.min(10, speed / 200);\n        if (o > 0.02) {\n          echoG.style.opacity = o.toFixed(3);\n          echoG.style.transform = `translateY(${(phase + Math.sign(vSmooth) * len).toFixed(2)}px)`;\n          echoG.style.filter = `blur(${(len / 2).toFixed(2)}px)`;\n        } else {\n          echoG.style.opacity = \"0\";\n          echoG.style.filter = \"none\";\n        }\n      }\n\n      const pxText = gap.toFixed(1);\n      if (pxText !== prevPxText) {\n        chipPx.textContent = pxText;\n        prevPxText = pxText;\n      }\n      const pctText = String(Math.round(pct));\n      if (pctText !== prevPctText) {\n        chipPct.textContent = pctText;\n        prevPctText = pctText;\n      }\n\n      // sleep: 150ms since last scroll AND every spring inside the epsilon\n      if (\n        now - lastScrollT > 150 &&\n        isSettled(jawTop) &&\n        isSettled(jawBot) &&\n        isSettled(needle)\n      ) {\n        echoG.style.opacity = \"0\"; // blur layer removed at rest for crispness\n        echoG.style.filter = \"none\";\n        vSmooth = 0;\n        running = false;\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!running) {\n        running = true;\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const onScroll = () => {\n      lastScrollT = performance.now();\n      wake();\n    };\n    scroller.addEventListener(\"scroll\", onScroll, { passive: true });\n\n    // 200ms accent flash of the readout label on section change\n    const flash = () => {\n      chipLabel.textContent = `SEC ${activeIdx + 1}/${total}`;\n      chipLabel.classList.remove(\"text-ns-muted\");\n      chipLabel.classList.add(\"text-ns-accent\");\n      if (flashTimer) clearTimeout(flashTimer);\n      flashTimer = setTimeout(() => {\n        chipLabel.classList.remove(\"text-ns-accent\");\n        chipLabel.classList.add(\"text-ns-muted\");\n      }, 200);\n    };\n\n    // throttled live region: 'Section 2 of 4, 38%'\n    const announce = () => {\n      const fire = () => {\n        lastAnnounce = performance.now();\n        live.textContent = `Section ${activeIdx + 1} of ${total}, ${Math.round(\n          Math.min(100, Math.max(0, needle.t))\n        )}%`;\n      };\n      if (announceTimer) clearTimeout(announceTimer);\n      const since = performance.now() - lastAnnounce;\n      if (since > 1200) fire();\n      else announceTimer = setTimeout(fire, 1200 - since);\n    };\n\n    // active section = the one with the most visible pixels in the scroller\n    const io = new IntersectionObserver(\n      (entries) => {\n        for (const e of entries) {\n          visiblePx.set(e.target, e.isIntersecting ? e.intersectionRect.height : 0);\n        }\n        let best = -1;\n        let bestH = 0;\n        sections.forEach((s, i) => {\n          const h = visiblePx.get(s) ?? 0;\n          if (h > bestH) {\n            bestH = h;\n            best = i;\n          }\n        });\n        if (best >= 0 && best !== activeIdx) {\n          activeIdx = best;\n          flash();\n          announce();\n          wake();\n        }\n      },\n      { root: scroller, threshold: [0, 0.2, 0.4, 0.6, 0.8, 1] }\n    );\n    sections.forEach((s) => io.observe(s));\n\n    const ro = new ResizeObserver(() => {\n      rebuild();\n      wake();\n    });\n    ro.observe(scroller);\n\n    if (total === 0) chipLabel.textContent = \"SEC —\";\n    wake(); // one primed frame, then immediate sleep until scroll\n\n    return () => {\n      cancelAnimationFrame(raf);\n      running = false;\n      io.disconnect();\n      ro.disconnect();\n      scroller.removeEventListener(\"scroll\", onScroll);\n      if (flashTimer) clearTimeout(flashTimer);\n      if (announceTimer) clearTimeout(announceTimer);\n    };\n  }, [\n    sectionSelector,\n    jawStiffness,\n    jawDamping,\n    needleStiffness,\n    needleDamping,\n  ]);\n\n  return (\n    <div ref={rootRef} className={`relative w-full overflow-hidden ${className}`}>\n      <div\n        ref={scrollerRef}\n        tabIndex={0}\n        role=\"region\"\n        aria-label=\"scrollable content, measured by caliper\"\n        className=\"h-full overflow-y-auto overscroll-contain pr-16 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n      >\n        {children ?? <FallbackSections />}\n      </div>\n\n      {/* instrument overlay — read-only, decorative; the live region below\n          carries the accessible signal */}\n      <div aria-hidden className=\"pointer-events-none absolute inset-0\">\n        <svg\n          ref={svgRef}\n          className=\"absolute inset-y-0 right-0 h-full w-12\"\n          viewBox={`0 0 ${W} 1`}\n          preserveAspectRatio=\"none\"\n          fill=\"none\"\n        >\n          {/* motion-blur echo of the tick scale (opacity/blur driven by\n              scroll velocity, removed at rest) */}\n          <g ref={echoRef} opacity={0}>\n            <path\n              ref={echoMinorRef}\n              className=\"text-border\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n            <path\n              ref={echoMajorRef}\n              className=\"text-foreground/60\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n          </g>\n          {/* main tick scale — translated by -(scrollTop % 8) per frame */}\n          <g ref={tickRef}>\n            <path\n              ref={minorRef}\n              className=\"text-border\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n            <path\n              ref={majorRef}\n              className=\"text-foreground/60\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n          </g>\n          {/* beam spine */}\n          <path\n            ref={spineRef}\n            className=\"text-border\"\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n          />\n          {/* measured span tint between the jaws */}\n          <rect\n            ref={regionRef}\n            x={2}\n            y={0}\n            width={W - 4}\n            height={0}\n            className=\"text-foreground\"\n            fill=\"currentColor\"\n            opacity={0.05}\n          />\n          {/* top jaw blade */}\n          <g ref={topJawRef} className=\"text-foreground\">\n            <path d={`M3 0H${W}`} stroke=\"currentColor\" strokeWidth={1.5} />\n            <path d=\"M3 0V-14\" stroke=\"currentColor\" strokeWidth={2.5} />\n            <path d=\"M3 0l12 -5v5z\" fill=\"currentColor\" />\n          </g>\n          {/* bottom jaw blade + vernier subscale */}\n          <g ref={botJawRef} className=\"text-foreground\">\n            <path d={`M3 0H${W}`} stroke=\"currentColor\" strokeWidth={1.5} />\n            <path d=\"M3 0V14\" stroke=\"currentColor\" strokeWidth={2.5} />\n            <path d=\"M3 0l12 5v-5z\" fill=\"currentColor\" />\n            <path\n              d=\"M30 4h8M30 7.5h8M30 11h8M30 14.5h8\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n              opacity={0.45}\n            />\n          </g>\n          {/* active-value marker — the only accent ink on the instrument */}\n          <g ref={markerRef} className=\"text-ns-accent\">\n            <path d={`M${W - 22} 0H${W}`} stroke=\"currentColor\" strokeWidth={1.5} />\n            <path d={`M${W - 22} 0l-5 -3.5v7z`} fill=\"currentColor\" />\n          </g>\n        </svg>\n\n        {/* digital readout chip */}\n        <div className=\"absolute bottom-3 right-14 rounded-sm border border-foreground/20 bg-surface px-2.5 py-1.5 font-mono text-[10px] leading-snug shadow-sm\">\n          <div\n            ref={chipLabelRef}\n            className=\"tracking-widest text-ns-muted transition-colors duration-200\"\n          >\n            SEC —/—\n          </div>\n          <div className=\"tabular-nums text-foreground\">\n            <span ref={chipPxRef}>0.0</span>\n            <span className=\"text-ns-muted\"> PX · </span>\n            <span ref={chipPctRef}>0</span>\n            <span className=\"text-ns-muted\">%</span>\n          </div>\n        </div>\n      </div>\n\n      {/* accessible signal — throttled section announcements */}\n      <div ref={liveRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/scroll-caliper.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "svg",
      "scroll",
      "instrument",
      "spring",
      "sections",
      "hud",
      "readout",
      "measurement"
    ],
    "instruction": "A vernier caliper instrument, ~48px wide, pinned to the right edge of a bounded overflow-y scroll container it measures. Built in SVG (tick scale, vernier subscale on the lower jaw, jaw blades, beam spine) plus a DOM font-mono readout chip; every transform is written directly in a rAF loop via refs, never React state. Sections are detected with an IntersectionObserver (root = the scroller) on [data-section] children, active = most visible pixels. The jaws map the active section's top/bottom to container-space y and chase those targets with a spring k=120 s^-2, zeta=0.9 (near-critical, a hair of lag); the readout needle (percent of the section swept past the container center) rides a softer spring k=90 s^-2, zeta=0.55 so it visibly over-settles and wobbles when scrolling stops. Tick scale: minor graduations every 2px (--border ink), majors every 8px and long index lines every 40px (--foreground ink), path d rebuilt only on resize and translated by -(scrollTop % 8) per frame so the scale scrolls with content. Motion blur: a duplicated tick layer offset along the scroll direction with opacity clamp(|v|/3000, 0, 0.6) and blur clamp(|v|/200, 0, 10)/2 px, velocity from per-frame scrollTop delta exponentially smoothed at alpha 0.2, layer removed entirely at rest for crispness. Section change flashes the readout label accent for 200ms and updates a throttled visually-hidden live region ('Section 2 of 4, 38%'); the instrument itself is aria-hidden and read-only, no fake affordances. The scroll listener is passive and only wakes the loop; the loop sleeps when 150ms have passed since the last scroll AND all three springs are inside the settle epsilon (|x-target| < 0.05, |v| < 0.05). All SVG ink is currentColor via token classes (text-border, text-foreground, text-ns-accent only on the active-value marker) so both themes restyle live with zero numeric color reads; the readout chip is bg-surface with border-foreground/20. Guards: zero-height containers skip rebuild, zero-height sections never divide, IntersectionObserver/ResizeObserver/scroll listener/flash and announce timers all torn down on unmount. prefers-reduced-motion: jaws and needle snap instantly, no blur layer, no wobble. The scroll container itself is an explicit tabIndex=0, role=\"region\" element with an aria-label ('scrollable content, measured by caliper'), so it is keyboard-focusable and scrollable cross-browser rather than depending on Chromium's implicit scroll-container focus heuristic (which WebKit/Safari does not ship)."
  },
  "type": "registry:ui"
}