{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "slider-vernier",
  "title": "Slider Vernier",
  "description": "Numeric input built as a true vernier caliper: a fixed coarse tick row over a sliding fine row at 0.9x pitch, where the accent-lit fine tick physically lines up with a main tick to spell the last digit. Drag the body for fast absolute moves, drag the vernier scale itself for one-step-per-tick precision.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/slider-vernier/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// VernierSlip — a numeric input built as a true vernier caliper: a fixed\n// coarse tick row (one tick per 10*step, \"UNIT\") sits above a sliding fine\n// row whose pitch is 0.9x the coarse pitch. Given quantized value v, let\n// totalSteps = round((v-min)/step), fineIdx = totalSteps mod 10 (0..9),\n// coarseIdx = (totalSteps-fineIdx)/10. Real vernier math: fine tick #fineIdx\n// coincides with main tick #(coarseIdx+fineIdx), not #coarseIdx — the\n// matching line drifts down the fixed scale as the reading grows, exactly\n// as on a physical caliper. That fine tick gets --ns-accent plus a hairline\n// back to its coincidence partner. Two drag zones on the same track: upper\n// half (\"body\") maps pointer position to value absolutely (fast, spans the\n// whole range); lower half (the vernier row itself) maps pointer DELTA to\n// value at one step per fine-tick-width (slow, precise). Both zones commit\n// through the same quantize(step) pipeline — the split changes sensitivity,\n// not resolution. Release, keyboard nav, and external value changes ease\n// the visual (never the already-committed value) toward the settled\n// position on an ease-out-expo tween; raw drag tracks the pointer 1:1 so\n// the coincidence is something you visibly chase into alignment. Direct-DOM\n// rAF for the tween only (sleeps otherwise); tick highlighting is ordinary\n// React state, since it only changes at quantized-value granularity. All\n// ink is currentColor via token utility classes — no canvas, no derived hex.\n// ---------------------------------------------------------------------------\n\nconst FINE_RATIO = 0.9; // vernier pitch = 0.9x coarse pitch — the actual geometry\nconst MIN_PITCH = 18;\nconst MAX_PITCH = 56;\nconst SETTLE_MS = 260;\nconst H = 84; // svg content height\nconst COARSE_BASELINE = 15;\nconst COARSE_TIP_MINOR = 24;\nconst COARSE_TIP_MAJOR = 30;\nconst COARSE_LABEL_Y = 8;\nconst FINE_BASELINE = 68;\nconst FINE_TIP = 54;\nconst FINE_LABEL_Y = 78;\n\nfunction easeOutExpo(t: number) {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\n\nexport interface VernierSlipProps {\n  /** controlled value; omit for uncontrolled */\n  value?: number;\n  /** uncontrolled initial value */\n  defaultValue?: number;\n  /** minimum allowed value */\n  min?: number;\n  /** maximum allowed value */\n  max?: number;\n  /** resolution — the fine (vernier) scale reveals exactly this last digit */\n  step?: number;\n  /** accessible name, also shown as the eyebrow caption */\n  label?: string;\n  /** unit suffix rendered beside the value, e.g. \"mg\", \"dB\" */\n  unit?: string;\n  /** aria-valuetext override */\n  formatValue?: (v: number) => string;\n  /** called with the new value on every drag/keyboard change */\n  onValueChange?: (value: number) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function VernierSlip({\n  value,\n  defaultValue = 1,\n  min = 0,\n  max = 10,\n  step = 0.1,\n  label = \"Value\",\n  unit,\n  formatValue,\n  onValueChange,\n  className = \"\",\n}: VernierSlipProps) {\n  const safeStep = step > 0 ? step : 0.1;\n  const safeMax = max > min ? max : min + safeStep * 10;\n  const decimals = (() => {\n    const f = String(safeStep).split(\".\")[1];\n    return f ? f.length : 0;\n  })();\n  const UNIT = safeStep * 10; // coarse pitch, in value units\n\n  const quantize = (v: number) => {\n    const q = min + Math.round((v - min) / safeStep) * safeStep;\n    return Number(Math.min(safeMax, Math.max(min, q)).toFixed(6));\n  };\n\n  const isControlled = value !== undefined;\n  const [internal, setInternal] = useState(() =>\n    quantize(defaultValue)\n  );\n  const current = isControlled ? quantize(value as number) : internal;\n  const currentRef = useRef(current);\n  currentRef.current = current;\n\n  const fmtValue =\n    formatValue ?? ((v: number) => `${v.toFixed(decimals)}${unit ? ` ${unit}` : \"\"}`);\n\n  // -- index math: which fine tick lights up, and which main tick it meets --\n  const totalSteps = Math.round((current - min) / safeStep);\n  const fineIdx = ((totalSteps % 10) + 10) % 10;\n  const coarseIdx = (totalSteps - fineIdx) / 10;\n  const matchIdx = coarseIdx + fineIdx;\n\n  const totalStepsMax = Math.round((safeMax - min) / safeStep);\n  const coarseIdxMax = Math.max(1, Math.ceil(totalStepsMax / 10));\n  // +9 padding so the coincidence tick (which can drift up to 9 ticks past\n  // the \"reading\" index) always has a rendered main tick to land on\n  const numCoarseTicks = coarseIdxMax + 9;\n\n  const [pitch, setPitch] = useState(32);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const fineGroupRef = useRef<SVGGElement>(null);\n  const hairRef = useRef<SVGLineElement>(null);\n  const pointerRef = useRef<SVGLineElement>(null);\n\n  const pitchRef = useRef(pitch);\n  pitchRef.current = pitch;\n  const matchIdxRef = useRef(matchIdx);\n  matchIdxRef.current = matchIdx;\n  const fineIdxRef = useRef(fineIdx);\n  fineIdxRef.current = fineIdx;\n\n  // fit coarse pitch to the container; falls back to horizontal scroll\n  // (track has overflow-x-auto) for domains too wide even at MIN_PITCH\n  useEffect(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const fit = (w: number) =>\n      Math.min(MAX_PITCH, Math.max(MIN_PITCH, w / Math.max(1, numCoarseTicks)));\n    setPitch(fit(track.clientWidth));\n    const ro = new ResizeObserver((entries) => {\n      const w = entries[0]?.contentRect.width ?? track.clientWidth;\n      setPitch(fit(w));\n    });\n    ro.observe(track);\n    return () => ro.disconnect();\n  }, [numCoarseTicks]);\n\n  const commitRef = useRef<(v: number) => void>(() => {});\n  commitRef.current = (v: number) => {\n    if (!isControlled) setInternal(v);\n    onValueChange?.(v);\n  };\n\n  const engineRef = useRef<{\n    dragStart: (clientX: number, clientY: number) => void;\n    dragMove: (clientX: number) => void;\n    dragEnd: () => void;\n    glideTo: (v: number) => void;\n  } | null>(null);\n\n  // -- drag / settle engine: direct DOM, refs only, never React state -------\n  useEffect(() => {\n    const track = trackRef.current;\n    const fineGroup = fineGroupRef.current;\n    const hair = hairRef.current;\n    const pointer = pointerRef.current;\n    if (!track || !fineGroup || !hair || !pointer) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    const localQuantize = (v: number) => {\n      const q = min + Math.round((v - min) / safeStep) * safeStep;\n      return Number(Math.min(safeMax, Math.max(min, q)).toFixed(6));\n    };\n\n    let raw = currentRef.current; // continuous, unquantized while dragging\n    let visX = 0; // current visual anchor of the fine group, world px\n    let targetX = 0;\n    let dragging = false;\n    let dragMode: \"coarse\" | \"fine\" | null = null;\n    let dragStartClientX = 0;\n    let dragStartValue = 0;\n    let raf = 0;\n    let tweenStart = 0;\n    let tweenFrom = 0;\n\n    const pxPerUnit = () => pitchRef.current / UNIT;\n    const xFor = (v: number) => (v - min) * pxPerUnit();\n\n    const render = (x: number) => {\n      visX = x;\n      fineGroup.style.transform = `translate3d(${x.toFixed(2)}px,0,0)`;\n      const finePitch = pitchRef.current * FINE_RATIO;\n      const hx2 = x + fineIdxRef.current * finePitch;\n      const hx1 = matchIdxRef.current * pitchRef.current;\n      hair.setAttribute(\"x1\", hx1.toFixed(2));\n      hair.setAttribute(\"x2\", hx2.toFixed(2));\n      pointer.setAttribute(\"x1\", x.toFixed(2));\n      pointer.setAttribute(\"x2\", x.toFixed(2));\n    };\n\n    render(xFor(raw));\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (dragging) return;\n      const t = Math.min(1, (now - tweenStart) / SETTLE_MS);\n      render(tweenFrom + (targetX - tweenFrom) * easeOutExpo(t));\n      if (t < 1) raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n\n    const settleTo = (v: number) => {\n      targetX = xFor(v);\n      raw = v;\n      if (reduced) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n        render(targetX);\n        return;\n      }\n      tweenFrom = visX;\n      tweenStart = performance.now();\n      wake();\n    };\n\n    const dragMove = (clientX: number) => {\n      if (!dragging) return;\n      const rect = track.getBoundingClientRect();\n      let nextRaw: number;\n      if (dragMode === \"coarse\") {\n        nextRaw = min + (clientX - rect.left) / pxPerUnit();\n      } else {\n        const finePitch = pitchRef.current * FINE_RATIO;\n        const deltaSteps = (clientX - dragStartClientX) / finePitch;\n        nextRaw = dragStartValue + deltaSteps * safeStep;\n      }\n      nextRaw = Math.min(safeMax, Math.max(min, nextRaw));\n      raw = nextRaw;\n      render(xFor(raw));\n      const q = localQuantize(raw);\n      if (q !== currentRef.current) commitRef.current(q);\n    };\n\n    const dragStart = (clientX: number, clientY: number) => {\n      const rect = track.getBoundingClientRect();\n      dragMode = clientY - rect.top < H / 2 ? \"coarse\" : \"fine\";\n      dragging = true;\n      dragStartClientX = clientX;\n      dragStartValue = currentRef.current;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      dragMove(clientX);\n    };\n\n    const dragEnd = () => {\n      if (!dragging) return;\n      dragging = false;\n      dragMode = null;\n      settleTo(currentRef.current);\n    };\n\n    const glideTo = (v: number) => {\n      if (dragging) return;\n      settleTo(v);\n    };\n\n    engineRef.current = { dragStart, dragMove, dragEnd, glideTo };\n\n    return () => {\n      cancelAnimationFrame(raf);\n      engineRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [min, safeMax, safeStep, UNIT]);\n\n  useEffect(() => {\n    engineRef.current?.glideTo(current);\n  }, [current]);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const v = currentRef.current;\n    let next: number | null = null;\n    switch (e.key) {\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = v + safeStep;\n        break;\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = v - safeStep;\n        break;\n      case \"PageUp\":\n        next = v + UNIT;\n        break;\n      case \"PageDown\":\n        next = v - UNIT;\n        break;\n      case \"Home\":\n        next = min;\n        break;\n      case \"End\":\n        next = safeMax;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    next = quantize(next);\n    if (next !== v) commitRef.current(next);\n  };\n\n  const finePitch = pitch * FINE_RATIO;\n  const labelStride = pitch < 26 ? 5 : pitch < 40 ? 2 : 1;\n\n  const coarseTicks = useMemo(() => {\n    const arr: { i: number; major: boolean; v: number }[] = [];\n    for (let i = 0; i <= numCoarseTicks; i++) {\n      arr.push({ i, major: i % labelStride === 0, v: min + i * UNIT });\n    }\n    return arr;\n  }, [numCoarseTicks, labelStride, min, UNIT]);\n\n  const fineTicks = useMemo(() => {\n    const arr: { k: number }[] = [];\n    for (let k = 0; k <= 10; k++) arr.push({ k });\n    return arr;\n  }, []);\n\n  const svgWidth = numCoarseTicks * pitch;\n  const str = current.toFixed(decimals);\n  const restStr = decimals > 0 ? str.slice(0, -1) : str;\n  const digitStr = decimals > 0 ? str.slice(-1) : \"\";\n\n  return (\n    <div\n      className={`w-full max-w-md rounded-md border border-border bg-surface p-4 font-mono ${className}`}\n    >\n      <div className=\"flex items-baseline justify-between gap-3 pb-3\">\n        <span className=\"text-[10px] uppercase tracking-[0.2em] text-ns-muted\">\n          {label}\n        </span>\n        <div className=\"flex items-baseline gap-1\">\n          <span\n            aria-hidden\n            className=\"text-2xl font-semibold tabular-nums text-foreground\"\n          >\n            {restStr}\n            {digitStr ? (\n              <span className=\"text-ns-accent\">{digitStr}</span>\n            ) : null}\n          </span>\n          {unit ? (\n            <span className=\"text-xs text-ns-muted\">{unit}</span>\n          ) : null}\n        </div>\n      </div>\n\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-label={label}\n        aria-orientation=\"horizontal\"\n        aria-valuemin={min}\n        aria-valuemax={safeMax}\n        aria-valuenow={current}\n        aria-valuetext={fmtValue(current)}\n        data-vernier-track\n        onKeyDown={onKeyDown}\n        onPointerDown={(e) => {\n          e.preventDefault();\n          trackRef.current?.setPointerCapture(e.pointerId);\n          trackRef.current?.focus({ preventScroll: true });\n          engineRef.current?.dragStart(e.clientX, e.clientY);\n        }}\n        onPointerMove={(e) => engineRef.current?.dragMove(e.clientX)}\n        onPointerUp={() => engineRef.current?.dragEnd()}\n        onPointerCancel={() => engineRef.current?.dragEnd()}\n        className=\"relative block w-full touch-none select-none overflow-x-auto rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface\"\n      >\n        <svg\n          width={svgWidth}\n          height={H}\n          className=\"block cursor-ew-resize\"\n          aria-hidden\n        >\n          {/* zone divider — subtle, purely decorative */}\n          <line\n            x1={0}\n            x2={svgWidth}\n            y1={H / 2}\n            y2={H / 2}\n            className=\"text-border\"\n            stroke=\"currentColor\"\n            strokeWidth={1}\n            strokeDasharray=\"1 4\"\n          />\n\n          {/* raw drag position — full-height guide, tracks the pointer 1:1 */}\n          <line\n            ref={pointerRef}\n            y1={0}\n            y2={H}\n            className=\"text-foreground/15\"\n            stroke=\"currentColor\"\n            strokeWidth={1}\n          />\n\n          {/* coarse row — fixed, never translates */}\n          <g>\n            {coarseTicks.map(({ i, major, v }) => {\n              const x = i * pitch;\n              const isMatch = i === matchIdx;\n              const tip = isMatch\n                ? COARSE_TIP_MAJOR + 4\n                : major\n                  ? COARSE_TIP_MAJOR\n                  : COARSE_TIP_MINOR;\n              return (\n                <g key={i}>\n                  <line\n                    x1={x}\n                    x2={x}\n                    y1={COARSE_BASELINE}\n                    y2={tip}\n                    className={isMatch ? \"text-ns-accent\" : \"text-foreground/70\"}\n                    stroke=\"currentColor\"\n                    strokeWidth={isMatch ? 2 : 1}\n                  />\n                  {major && pitch >= 22 ? (\n                    <text\n                      x={x}\n                      y={COARSE_LABEL_Y}\n                      textAnchor={x < 6 ? \"start\" : x > svgWidth - 6 ? \"end\" : \"middle\"}\n                      className={\n                        isMatch\n                          ? \"fill-ns-accent font-medium\"\n                          : \"fill-ns-muted\"\n                      }\n                      style={{ fontSize: 9 }}\n                    >\n                      {v.toFixed(Math.max(0, decimals - 1))}\n                    </text>\n                  ) : null}\n                </g>\n              );\n            })}\n            <line\n              x1={0}\n              x2={svgWidth}\n              y1={COARSE_BASELINE}\n              y2={COARSE_BASELINE}\n              className=\"text-border\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n          </g>\n\n          {/* connecting hairline — the coincidence, drawn live each frame */}\n          <line\n            ref={hairRef}\n            y1={COARSE_TIP_MAJOR}\n            y2={FINE_TIP}\n            className=\"text-ns-accent\"\n            stroke=\"currentColor\"\n            strokeWidth={1.25}\n            strokeDasharray=\"2 2\"\n          />\n\n          {/* fine (vernier) row — a rigid group that translates as one body */}\n          <g ref={fineGroupRef}>\n            <line\n              x1={0}\n              x2={10 * finePitch}\n              y1={FINE_BASELINE}\n              y2={FINE_BASELINE}\n              className=\"text-border\"\n              stroke=\"currentColor\"\n              strokeWidth={1}\n            />\n            {fineTicks.map(({ k }) => {\n              const x = k * finePitch;\n              const isMatch = k === fineIdx;\n              return (\n                <g key={k}>\n                  <line\n                    x1={x}\n                    x2={x}\n                    y1={FINE_BASELINE}\n                    y2={isMatch ? FINE_TIP - 4 : FINE_TIP}\n                    className={\n                      isMatch\n                        ? \"text-ns-accent\"\n                        : k <= 9\n                          ? \"text-foreground/70\"\n                          : \"text-foreground/30\"\n                    }\n                    stroke=\"currentColor\"\n                    strokeWidth={isMatch ? 2 : 1}\n                  />\n                  {k <= 9 ? (\n                    <text\n                      x={x}\n                      y={FINE_LABEL_Y}\n                      textAnchor=\"middle\"\n                      className={\n                        isMatch ? \"fill-ns-accent font-semibold\" : \"fill-ns-muted\"\n                      }\n                      style={{ fontSize: 9 }}\n                    >\n                      {k}\n                    </text>\n                  ) : null}\n                </g>\n              );\n            })}\n          </g>\n        </svg>\n      </div>\n\n      <div className=\"flex items-center justify-between pt-2 font-mono text-[10px] text-ns-muted\">\n        <span>drag body to set · drag scale below to fine-tune</span>\n        <span aria-hidden className=\"tabular-nums\">\n          {min.toFixed(Math.max(0, decimals - 1))}–{safeMax.toFixed(Math.max(0, decimals - 1))}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/slider-vernier.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": [
      "slider",
      "input",
      "numeric",
      "precision",
      "svg",
      "drag",
      "form",
      "micro-interaction"
    ],
    "instruction": "Build a numeric input as a working vernier caliper, not a decorated slider. GEOMETRY: a fixed coarse tick row (one tick per 10*step) sits above a sliding fine row of 11 ticks whose pitch is exactly 0.9x the coarse pitch. For quantized value v, totalSteps = round((v-min)/step), fineIdx = totalSteps mod 10, coarseIdx = (totalSteps-fineIdx)/10; the real caliper identity is that fine tick #fineIdx coincides with MAIN tick #(coarseIdx+fineIdx) — not #coarseIdx — so the coincidence point drifts down the fixed scale as the reading grows, exactly like the physical instrument. The matching fine tick and its coincidence partner both light --ns-accent, joined by a dashed accent hairline redrawn every frame; render 9 extra padding ticks past the domain max so the coincidence always has a partner to land on. INTERACTION: one track, two drag zones split at half height — the upper half maps pointer position to value ABSOLUTELY (fast, spans the whole range), the lower half (the vernier row itself) maps pointer DELTA at one step per fine-tick-width (slow, precise); both commit through the same quantize(step) pipeline so the split changes sensitivity, never resolution. While dragging, the fine group tracks the pointer 1:1 (raw, unquantized) so the user visibly chases the coincidence into alignment; on release, keyboard nav, or external value change, the visual eases to the settled quantized position on a 260ms ease-out-expo tween. The tween is direct-DOM rAF writing transform/x attributes to refs (sleeps when settled, cancelled on unmount); tick highlighting is ordinary React state since it only changes at quantized-value granularity. prefers-reduced-motion snaps to the settled position with no tween. Pointer capture on the track; a faint full-height guide line tracks the raw pointer. ACCESSIBILITY: the track is role=slider tabIndex=0 with aria-valuemin/max/now and aria-valuetext (formatValue override supported), ArrowLeft/Right/Up/Down step by step, PageUp/Down by 10*step, Home/End to the rails; the SVG is aria-hidden and the big mono readout (last digit tinted --ns-accent to mirror the vernier) is aria-hidden too, since the slider itself carries the value. LAYOUT: coarse pitch auto-fits the container via ResizeObserver (clamped 18-56px, labels drop below 22px, label stride widens as pitch shrinks), overflowing domains fall back to horizontal scroll. INK: everything is currentColor via token utility classes (text-foreground/70, text-border, text-ns-accent, fill-ns-muted) — no canvas, no hex."
  },
  "type": "registry:ui"
}