{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "xy-pad",
  "type": "registry:ui",
  "title": "XY Pad",
  "description": "A 2D control surface: drag a puck to set two values at once, with grid, crosshair, readout and keyboard nudging.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/xy-pad.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useTransform } from \"motion/react\";\n\nexport interface XYValue {\n  x: number;\n  y: number;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\n\nexport interface XYPadProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Current value (controlled). */\n  value?: XYValue;\n  /** Initial value (uncontrolled). Defaults to the centre. */\n  defaultValue?: XYValue;\n  /** Called whenever the stepped value changes (while dragging, too). */\n  onValueChange?: (value: XYValue) => void;\n  /** Called when a drag ends and after keyboard changes. */\n  onValueCommit?: (value: XYValue) => void;\n  /** [min, max] of the horizontal axis. */\n  xRange?: [number, number];\n  /** [min, max] of the vertical axis. Larger values are higher up. */\n  yRange?: [number, number];\n  /** Step for both axes, or `[xStep, yStep]`. */\n  step?: number | [number, number];\n  xLabel?: string;\n  yLabel?: string;\n  /** Formats the readout and aria-valuetext for each axis (e.g. units). */\n  formatX?: (value: number) => string;\n  formatY?: (value: number) => string;\n  /** Accessible name of the pad. */\n  label?: string;\n  /** Number of grid divisions per axis. */\n  gridLines?: number;\n  /** Draw guide lines through the puck. */\n  crosshair?: boolean;\n  /** Spring the puck back to the centre on release, like a joystick. */\n  returnToCenter?: boolean;\n  /** Spring used for keyboard moves, releases and controlled updates. */\n  spring?: Spring;\n  /** Show the live value readout in the corner. */\n  showReadout?: boolean;\n  /** Puck, crosshair and focus colour. */\n  accent?: string;\n  disabled?: boolean;\n}\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\n\nfunction decimalsOf(step: number) {\n  const s = String(step);\n  return s.includes(\".\") ? s.length - s.indexOf(\".\") - 1 : 0;\n}\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/**\n * A two-dimensional control surface, like a synth XY pad: drag the puck anywhere to set two\n * values at once. The puck follows the pointer through motion values (no re-render per move);\n * state only changes when a stepped value changes. Exposed as one role=\"slider\" whose\n * aria-valuetext describes both axes; arrow keys nudge each axis.\n */\nexport function XYPad({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  xRange = [0, 100],\n  yRange = [0, 100],\n  step = 1,\n  xLabel = \"X\",\n  yLabel = \"Y\",\n  formatX,\n  formatY,\n  label = \"XY pad\",\n  gridLines = 4,\n  crosshair = true,\n  returnToCenter = false,\n  spring = { stiffness: 300, damping: 22, mass: 0.7 },\n  showReadout = true,\n  accent = \"#FF7448\",\n  disabled = false,\n  className = \"\",\n  style,\n  ...props\n}: XYPadProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const [xMin, xMax] = xRange;\n  const [yMin, yMax] = yRange;\n  const [xStep, yStep] = typeof step === \"number\" ? [step, step] : step;\n  const showX = formatX ?? ((v: number) => v.toFixed(decimalsOf(xStep)));\n  const showY = formatY ?? ((v: number) => v.toFixed(decimalsOf(yStep)));\n\n  /** Snaps to the axis step grid (anchored at the axis minimum) and clamps to the range. */\n  const snapAxis = (raw: number, lo: number, hi: number, axisStep: number) => {\n    const d = decimalsOf(axisStep);\n    return clamp(Number((Math.round((raw - lo) / axisStep) * axisStep + lo).toFixed(d)), Math.min(lo, hi), Math.max(lo, hi));\n  };\n  const snapX = (raw: number) => snapAxis(raw, xMin, xMax, xStep);\n  const snapY = (raw: number) => snapAxis(raw, yMin, yMax, yStep);\n  const centre: XYValue = { x: snapX((xMin + xMax) / 2), y: snapY((yMin + yMax) / 2) };\n\n  const [internal, setInternal] = React.useState<XYValue>(() => defaultValue ?? centre);\n  const current = valueProp ?? internal;\n  const x = snapX(current.x);\n  const y = snapY(current.y);\n\n  /** Fractions across the pad: 0 = left / top, 1 = right / bottom. */\n  const toFraction = (v: XYValue) => ({\n    fx: (v.x - xMin) / (xMax - xMin || 1),\n    fy: (yMax - v.y) / (yMax - yMin || 1),\n  });\n  const initial = toFraction({ x, y });\n  const fx = useMotionValue(initial.fx);\n  const fy = useMotionValue(initial.fy);\n  const left = useTransform(fx, (v) => `${Math.round(v * 100000) / 1000}%`);\n  const top = useTransform(fy, (v) => `${Math.round(v * 100000) / 1000}%`);\n\n  const padRef = React.useRef<HTMLDivElement>(null);\n  const draggingRef = React.useRef(false);\n  const latest = React.useRef<XYValue>({ x, y });\n  const [dragging, setDragging] = React.useState(false);\n\n  const moveTo = (target: XYValue) => {\n    const f = toFraction(target);\n    if (reduceMotion) {\n      fx.set(f.fx);\n      fy.set(f.fy);\n      return;\n    }\n    const options = { type: \"spring\" as const, ...spring };\n    animate(fx, f.fx, options);\n    animate(fy, f.fy, options);\n  };\n\n  // Follow value changes that don't come from a drag (keyboard, controlled updates, return to centre).\n  const syncPuck = React.useEffectEvent((target: XYValue) => {\n    if (!draggingRef.current) moveTo(target);\n  });\n  React.useEffect(() => {\n    syncPuck({ x, y });\n  }, [x, y]);\n\n  const commit = (next: XYValue) => {\n    latest.current = next;\n    if (next.x === x && next.y === y) return;\n    if (valueProp === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  const fromPointer = (clientX: number, clientY: number) => {\n    const rect = padRef.current?.getBoundingClientRect();\n    if (!rect || !rect.width || !rect.height) return;\n    const px = clamp((clientX - rect.left) / rect.width, 0, 1);\n    const py = clamp((clientY - rect.top) / rect.height, 0, 1);\n    // The puck tracks the raw pointer; the value snaps to the step grid.\n    fx.set(px);\n    fy.set(py);\n    commit({ x: snapX(xMin + px * (xMax - xMin)), y: snapY(yMax - py * (yMax - yMin)) });\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    event.currentTarget.focus({ preventScroll: true });\n    draggingRef.current = true;\n    latest.current = { x, y };\n    setDragging(true);\n    fromPointer(event.clientX, event.clientY);\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (draggingRef.current) fromPointer(event.clientX, event.clientY);\n  };\n\n  const endDrag = () => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    setDragging(false);\n    const target = returnToCenter ? centre : latest.current;\n    commit(target);\n    moveTo(target);\n    onValueCommit?.(target);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const factor = event.shiftKey ? 10 : 1;\n    let next: XYValue;\n    switch (event.key) {\n      case \"ArrowLeft\":\n        next = { x: x - xStep * factor, y };\n        break;\n      case \"ArrowRight\":\n        next = { x: x + xStep * factor, y };\n        break;\n      case \"ArrowUp\":\n        next = { x, y: y + yStep * factor };\n        break;\n      case \"ArrowDown\":\n        next = { x, y: y - yStep * factor };\n        break;\n      case \"PageUp\":\n        next = { x, y: y + yStep * 10 };\n        break;\n      case \"PageDown\":\n        next = { x, y: y - yStep * 10 };\n        break;\n      case \"Home\":\n        next = centre;\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n    const snapped = { x: snapX(next.x), y: snapY(next.y) };\n    commit(snapped);\n    onValueCommit?.(snapped);\n  };\n\n  const lines = Math.max(1, Math.round(gridLines));\n  const ticks = Array.from({ length: lines - 1 }, (_, i) => Number((((i + 1) / lines) * 100).toFixed(2)));\n  const xText = showX(x);\n  const yText = showY(y);\n\n  return (\n    <div\n      className={`grid w-full select-none grid-cols-[auto_minmax(0,1fr)] gap-2 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--xy-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      {/* Vertical axis label (decorative: the slider's valuetext names both axes). */}\n      <div aria-hidden=\"true\" className=\"flex min-w-4 flex-col items-end justify-between py-1 font-mono text-[9px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400\">\n        <span>{showY(yMax)}</span>\n        <span className=\"self-center uppercase tracking-[0.14em] [writing-mode:vertical-rl] rotate-180\">{yLabel}</span>\n        <span>{showY(yMin)}</span>\n      </div>\n\n      <div\n        ref={padRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-roledescription=\"2D slider\"\n        aria-valuemin={Math.min(xMin, xMax)}\n        aria-valuemax={Math.max(xMin, xMax)}\n        aria-valuenow={x}\n        aria-valuetext={`${xLabel} ${xText}, ${yLabel} ${yText}`}\n        aria-disabled={disabled || undefined}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onLostPointerCapture={endDrag}\n        onKeyDown={handleKeyDown}\n        className={`relative aspect-square min-w-0 touch-none overflow-hidden rounded-2xl border border-zinc-900/10 bg-white outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--xy-accent)] dark:border-white/10 dark:bg-zinc-900/60 ${\n          disabled ? \"cursor-not-allowed\" : dragging ? \"cursor-grabbing\" : \"cursor-crosshair\"\n        }`}\n      >\n        {/* Grid: thin divisions with a stronger centre cross. */}\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 size-full text-zinc-900 dark:text-white\">\n          {ticks.map((t) => (\n            <g key={t} stroke=\"currentColor\" strokeOpacity={t === 50 ? 0.14 : 0.06} strokeWidth=\"1\" shapeRendering=\"crispEdges\">\n              <line x1={`${t}%`} x2={`${t}%`} y1=\"0\" y2=\"100%\" />\n              <line y1={`${t}%`} y2={`${t}%`} x1=\"0\" x2=\"100%\" />\n            </g>\n          ))}\n        </svg>\n\n        {crosshair && (\n          <>\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-y-0 w-px -translate-x-1/2 bg-[var(--xy-accent)] opacity-50\"\n              style={{ left }}\n            />\n            <motion.span\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-x-0 h-px -translate-y-1/2 bg-[var(--xy-accent)] opacity-50\"\n              style={{ top }}\n            />\n          </>\n        )}\n\n        {showReadout && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-2.5 top-2.5 whitespace-nowrap rounded-md border border-zinc-900/10 bg-white/85 px-2 py-1 font-mono text-[10px] tabular-nums tracking-[0.04em] text-zinc-700 backdrop-blur-sm dark:border-white/10 dark:bg-zinc-950/70 dark:text-zinc-200\"\n          >\n            <span className=\"text-zinc-400 dark:text-zinc-500\">{xLabel}</span> {xText}\n            <span className=\"mx-1.5 text-zinc-300 dark:text-zinc-600\">/</span>\n            <span className=\"text-zinc-400 dark:text-zinc-500\">{yLabel}</span> {yText}\n          </div>\n        )}\n\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full\"\n          style={{ left, top }}\n        >\n          <span\n            className={`absolute inset-0 rounded-full bg-[var(--xy-accent)] opacity-25 transition-transform duration-200 motion-reduce:transition-none ${\n              dragging ? \"scale-[1.9]\" : \"scale-100\"\n            }`}\n          />\n          <span className=\"relative size-3.5 rounded-full border-2 border-white bg-[var(--xy-accent)] shadow-[0_1px_4px_rgb(0_0_0/0.3)] dark:border-zinc-950\" />\n        </motion.span>\n      </div>\n\n      {/* Horizontal axis label. */}\n      <div aria-hidden=\"true\" className=\"col-start-2 flex justify-between font-mono text-[9px] tabular-nums tracking-[0.06em] text-zinc-500 dark:text-zinc-400\">\n        <span>{showX(xMin)}</span>\n        <span className=\"uppercase tracking-[0.14em]\">{xLabel}</span>\n        <span>{showX(xMax)}</span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/xy-pad"
}
