{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "caliper",
  "type": "registry:ui",
  "title": "Caliper",
  "description": "Before/after comparison slider that clips a leading layer with a draggable divider. Machinist's-caliper hairline with tick marks, a knurled grip, a live vernier readout, and a 50% detent. Accepts arbitrary children, keyboard-accessible, hover/drag/autoplay modes.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json",
    "https://crucibleui.com/r/use-visibility-pause.json"
  ],
  "files": [
    {
      "path": "registry/default/utility/caliper/caliper.tsx",
      "type": "registry:ui",
      "target": "components/crucible/caliper.tsx",
      "content": "\"use client\";\n\nimport { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CaliperProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\"> {\n  /** Leading content — revealed from the start edge up to the divider (the \"before\"). */\n  before: React.ReactNode;\n  /** Trailing content — fills the frame beneath the leading layer (the \"after\"). */\n  after: React.ReactNode;\n  /** Starting split as a percentage, 0–100. @default 50 */\n  initial?: number;\n  /** `drag` requires a press to move the divider; `hover` follows the pointer on move. @default \"drag\" */\n  mode?: \"drag\" | \"hover\";\n  /** Divider axis. `vertical` splits left/right; `horizontal` splits top/bottom. @default \"vertical\" */\n  orientation?: \"vertical\" | \"horizontal\";\n  /** Render the knurled grip on the divider. @default true */\n  handle?: boolean;\n  /** Sweep the divider back and forth on a timer while idle (ignored under reduced motion). @default false */\n  autoplay?: boolean;\n  /** Seconds for one full autoplay sweep, there and back. @default 6 */\n  autoplayDuration?: number;\n  /** Beam / tick / grip color — the bright neutral hairline. @default \"#f5f5f5\" */\n  color?: string;\n  /** Cool accent for the live readout and detent pulse. @default \"#7dd3fc\" */\n  accent?: string;\n  /** Optional short chip pinned to the leading side. */\n  beforeLabel?: React.ReactNode;\n  /** Optional short chip pinned to the trailing side. */\n  afterLabel?: React.ReactNode;\n  /** Fires with the rounded split percentage whenever the divider settles. */\n  onSplitChange?: (percent: number) => void;\n  /** Extra classes for the leading (before) layer. */\n  beforeClassName?: string;\n  /** Extra classes for the trailing (after) layer. */\n  afterClassName?: string;\n}\n\nconst clamp = (n: number) => Math.min(100, Math.max(0, n));\n\n// Drag inertia — the beam has a whisper of mass, then settles crisply.\nconst SPRING = { stiffness: 320, damping: 30, mass: 0.7 } as const;\n\n/**\n * Caliper — a before/after comparison slider. Two layers occupy one frame; a\n * vertical (or horizontal) divider clips the leading layer so dragging — or\n * hovering across, in hover mode — sweeps the reveal boundary and lets the eye\n * compare two states. Accepts arbitrary children, not just images: diff two\n * entire component trees.\n *\n * Crucible signature: a machinist's caliper — neutral beam with one cool accent.\n * The divider is a thin bright hairline capped with fine tick marks and a\n * knurled grip; a tiny live percentage readout fades in while you drag and\n * evaporates on release, like reading a vernier scale. The beam moves with a\n * whisper of spring inertia, a 1px detent tick brightens as it crosses 50%, and\n * the grip is a focusable slider — arrows nudge 1%, Home/End snap to either\n * side, value announced. Under reduced motion the spring and autoplay are\n * dropped for direct 1:1 tracking. Clip-path compositing only; no per-frame\n * layout.\n */\nexport function Caliper({\n  before,\n  after,\n  initial = 50,\n  mode = \"drag\",\n  orientation = \"vertical\",\n  handle = true,\n  autoplay = false,\n  autoplayDuration = 6,\n  color = \"#f5f5f5\",\n  accent = \"#7dd3fc\",\n  beforeLabel,\n  afterLabel,\n  onSplitChange,\n  beforeClassName,\n  afterClassName,\n  className,\n  ...props\n}: CaliperProps) {\n  const reducedMotion = useReducedMotion();\n  const isVertical = orientation === \"vertical\";\n\n  const raw = useMotionValue(clamp(initial));\n  const springed = useSpring(raw, SPRING);\n  const tracked = reducedMotion ? raw : springed;\n\n  const activeRef = React.useRef(false);\n  const [interacting, setInteracting] = React.useState(false);\n  // Once the user grabs the divider, autoplay yields for good (no jarring resume).\n  const [engaged, setEngaged] = React.useState(false);\n  const [display, setDisplay] = React.useState(() => Math.round(clamp(initial)));\n  const displayRef = React.useRef(display);\n  const hideTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const onChangeRef = React.useRef(onSplitChange);\n  onChangeRef.current = onSplitChange;\n\n  // Keep the announced value + readout in sync with the tracked value, but only\n  // re-render on whole-percent changes so a full sweep costs ~100 updates, not\n  // one per frame. The clip itself is a pure MotionValue transform (no React).\n  useMotionValueEvent(tracked, \"change\", (v) => {\n    const r = Math.round(v);\n    if (r !== displayRef.current) {\n      displayRef.current = r;\n      setDisplay(r);\n    }\n  });\n\n  const clipPath = useTransform(tracked, (p) =>\n    isVertical ? `inset(0 ${100 - p}% 0 0)` : `inset(0 0 ${100 - p}% 0)`\n  );\n  const linePos = useTransform(tracked, (p) => `${p}%`);\n\n  const commit = React.useCallback(() => {\n    onChangeRef.current?.(Math.round(raw.get()));\n  }, [raw]);\n\n  const set = React.useCallback(\n    (next: number) => {\n      raw.set(clamp(next));\n    },\n    [raw]\n  );\n\n  // Autoplay: a cosine sweep between two insets, paused while the user is\n  // interacting, offscreen, or on a hidden tab. Reduced motion opts out entirely.\n  const autoplayPaused = !autoplay || reducedMotion || interacting || engaged;\n  const rootRef = useVisibilityPause<HTMLDivElement>(\n    (elapsed) => {\n      const period = Math.max(0.5, autoplayDuration);\n      const phase = (elapsed % period) / period;\n      const eased = 0.5 - 0.5 * Math.cos(phase * Math.PI * 2);\n      raw.set(8 + eased * 84);\n    },\n    { paused: autoplayPaused }\n  );\n\n  const updateFromPointer = React.useCallback(\n    (clientX: number, clientY: number) => {\n      const el = rootRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      const p = isVertical\n        ? ((clientX - rect.left) / rect.width) * 100\n        : ((clientY - rect.top) / rect.height) * 100;\n      set(p);\n    },\n    [isVertical, rootRef, set]\n  );\n\n  const showReadout = React.useCallback(() => {\n    if (hideTimer.current) {\n      clearTimeout(hideTimer.current);\n      hideTimer.current = null;\n    }\n    setInteracting(true);\n  }, []);\n\n  const scheduleHide = React.useCallback((ms: number) => {\n    if (hideTimer.current) clearTimeout(hideTimer.current);\n    hideTimer.current = setTimeout(() => setInteracting(false), ms);\n  }, []);\n\n  React.useEffect(\n    () => () => {\n      if (hideTimer.current) clearTimeout(hideTimer.current);\n    },\n    []\n  );\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (e.button !== 0 && e.pointerType === \"mouse\") return;\n    e.currentTarget.setPointerCapture(e.pointerId);\n    activeRef.current = true;\n    setEngaged(true);\n    showReadout();\n    updateFromPointer(e.clientX, e.clientY);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (activeRef.current) {\n      updateFromPointer(e.clientX, e.clientY);\n      return;\n    }\n    if (mode === \"hover\" && e.pointerType === \"mouse\") {\n      showReadout();\n      updateFromPointer(e.clientX, e.clientY);\n    }\n  };\n\n  const endPress = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!activeRef.current) return;\n    activeRef.current = false;\n    try {\n      e.currentTarget.releasePointerCapture(e.pointerId);\n    } catch {\n      /* pointer already released */\n    }\n    commit();\n    if (mode === \"drag\" || e.pointerType !== \"mouse\") setInteracting(false);\n  };\n\n  const onPointerLeave = () => {\n    if (mode === \"hover\" && !activeRef.current) {\n      setInteracting(false);\n      // Hover mode has no release — the split settles when the pointer leaves.\n      commit();\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const current = Math.round(raw.get());\n    let next: number | null = null;\n    switch (e.key) {\n      case \"ArrowLeft\":\n      case \"ArrowDown\":\n        next = current - 1;\n        break;\n      case \"ArrowRight\":\n      case \"ArrowUp\":\n        next = current + 1;\n        break;\n      case \"PageDown\":\n        next = current - 10;\n        break;\n      case \"PageUp\":\n        next = current + 10;\n        break;\n      case \"Home\":\n        next = 0;\n        break;\n      case \"End\":\n        next = 100;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    setEngaged(true);\n    set(next);\n    commit();\n    showReadout();\n    scheduleHide(1000);\n  };\n\n  const nearDetent = Math.abs(display - 50) <= 1;\n\n  // Grip orientation: a pill along the divider with knurl lines and drag chevrons.\n  const grip = handle ? (\n    <div\n      role=\"slider\"\n      tabIndex={0}\n      aria-label=\"Comparison split\"\n      aria-orientation={orientation}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={display}\n      aria-valuetext={`${display}% revealed`}\n      onKeyDown={onKeyDown}\n      className={cn(\n        \"pointer-events-auto absolute top-1/2 left-1/2 flex -translate-x-1/2 -translate-y-1/2 touch-none select-none items-center justify-center rounded-full border outline-none\",\n        \"border-white/20 bg-neutral-950/70 shadow-[0_2px_12px_rgba(0,0,0,0.55)] backdrop-blur-sm\",\n        \"transition-[transform,box-shadow,border-color] duration-200 hover:scale-105\",\n        \"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n        isVertical ? \"h-11 w-7 cursor-ew-resize flex-col gap-[3px]\" : \"h-7 w-11 cursor-ns-resize flex-row gap-[3px]\"\n      )}\n      style={\n        {\n          \"--tw-ring-color\": accent,\n          boxShadow: interacting ? `0 0 0 1px ${color}55, 0 2px 16px rgba(0,0,0,0.6)` : undefined,\n        } as React.CSSProperties\n      }\n    >\n      {/* knurl lines — fine ridges across the grip face */}\n      <span aria-hidden className={cn(\"flex\", isVertical ? \"flex-col gap-[3px]\" : \"flex-row gap-[3px]\")}>\n        <i className={cn(\"block rounded-full opacity-70\", isVertical ? \"h-px w-3\" : \"h-3 w-px\")} style={{ background: color }} />\n        <i className={cn(\"block rounded-full opacity-70\", isVertical ? \"h-px w-3\" : \"h-3 w-px\")} style={{ background: color }} />\n        <i className={cn(\"block rounded-full opacity-70\", isVertical ? \"h-px w-3\" : \"h-3 w-px\")} style={{ background: color }} />\n      </span>\n    </div>\n  ) : null;\n\n  return (\n    <div\n      ref={rootRef}\n      data-crucible=\"caliper\"\n      className={cn(\n        \"relative isolate h-full w-full touch-none select-none overflow-hidden rounded-xl\",\n        mode === \"hover\" ? \"cursor-crosshair\" : isVertical ? \"cursor-ew-resize\" : \"cursor-ns-resize\",\n        className\n      )}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endPress}\n      onPointerCancel={endPress}\n      onPointerLeave={onPointerLeave}\n      {...props}\n    >\n      {/* Trailing layer (after) — fills the frame */}\n      <div className={cn(\"absolute inset-0\", afterClassName)}>{after}</div>\n\n      {/* Leading layer (before) — clipped to the split via clip-path only */}\n      <motion.div className={cn(\"absolute inset-0\", beforeClassName)} style={{ clipPath, willChange: \"clip-path\" }}>\n        {before}\n      </motion.div>\n\n      {/* Optional side labels */}\n      {(beforeLabel || afterLabel) && (\n        <div className=\"pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between p-3\">\n          <span className=\"rounded-full border border-white/10 bg-neutral-950/60 px-2.5 py-1 text-[11px] font-medium tracking-wide text-neutral-300 backdrop-blur-sm\">\n            {beforeLabel}\n          </span>\n          <span className=\"rounded-full border border-white/10 bg-neutral-950/60 px-2.5 py-1 text-[11px] font-medium tracking-wide text-neutral-300 backdrop-blur-sm\">\n            {afterLabel}\n          </span>\n        </div>\n      )}\n\n      {/* Static 50% detent reference — a 1px tick that brightens as the beam nears it */}\n      <div\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute z-10 transition-opacity duration-200\",\n          isVertical ? \"top-0 left-1/2 h-2.5 w-px -translate-x-1/2\" : \"top-1/2 left-0 h-px w-2.5 -translate-y-1/2\"\n        )}\n        style={{ background: nearDetent ? accent : color, opacity: nearDetent ? 0.95 : 0.28 }}\n      />\n\n      {/* Divider — the bright beam, capped with tick marks, carrying the grip.\n          Not aria-hidden as a whole: the grip inside is the focusable slider. */}\n      <motion.div\n        className={cn(\"pointer-events-none absolute z-10\", isVertical ? \"inset-y-0\" : \"inset-x-0\")}\n        style={isVertical ? { left: linePos } : { top: linePos }}\n      >\n        <div aria-hidden className={cn(\"absolute\", isVertical ? \"inset-y-0 left-1/2 -translate-x-1/2\" : \"inset-x-0 top-1/2 -translate-y-1/2\")}>\n          {/* the hairline beam + soft glow */}\n          <div\n            className={cn(\"absolute\", isVertical ? \"inset-y-0 left-1/2 w-px -translate-x-1/2\" : \"inset-x-0 top-1/2 h-px -translate-y-1/2\")}\n            style={{ background: color, boxShadow: `0 0 10px ${color}66` }}\n          />\n          {/* caliper jaw ticks at both ends */}\n          <span\n            className={cn(\"absolute\", isVertical ? \"top-0 left-1/2 h-px w-2.5 -translate-x-1/2\" : \"left-0 top-1/2 h-2.5 w-px -translate-y-1/2\")}\n            style={{ background: color, opacity: 0.85 }}\n          />\n          <span\n            className={cn(\"absolute\", isVertical ? \"bottom-0 left-1/2 h-px w-2.5 -translate-x-1/2\" : \"right-0 top-1/2 h-2.5 w-px -translate-y-1/2\")}\n            style={{ background: color, opacity: 0.85 }}\n          />\n        </div>\n\n        {/* live readout — fades in while interacting, evaporates on release */}\n        <div\n          aria-hidden\n          className={cn(\n            \"absolute left-1/2 flex -translate-x-1/2 items-center justify-center transition-all duration-200\",\n            isVertical ? \"top-2\" : \"top-1/2 -translate-y-1/2 ml-4\",\n            interacting ? \"opacity-100 blur-0\" : \"translate-y-0 opacity-0 blur-[2px]\"\n          )}\n        >\n          <span\n            className=\"rounded-md border border-white/15 bg-neutral-950/80 px-2 py-0.5 text-[11px] font-semibold tabular-nums backdrop-blur-sm\"\n            style={{ color: accent, textShadow: `0 0 8px ${accent}55` }}\n          >\n            {display}%\n          </span>\n        </div>\n\n        {grip}\n      </motion.div>\n    </div>\n  );\n}\n"
    }
  ]
}