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