XY Pad
import { useEffect, useRef, useState } from 'react';
const css = `
.field-xy-pad { user-select: none; -webkit-user-select: none; width: 230px; }
.field-xy-pad__label { display: flex; justify-content: space-between; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-xy-pad__label b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.field-xy-pad__pad {
position: relative;
height: 130px;
border-radius: 12px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 26px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 23px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
cursor: crosshair;
touch-action: none;
outline: none;
}
.field-xy-pad__pad:focus-visible { box-shadow: inset 0 0 0 2px light-dark(#5f626a, #a7abb2); }
.field-xy-pad__pad canvas { position: absolute; inset: 0; width: 230px; height: 130px; }
.field-xy-pad__axis { display: flex; justify-content: space-between; margin-top: 5px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; }
.field-xy-pad__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.field-xy-pad__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.field-xy-pad__probe i:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }
`;
const W = 230;
const H = 130;
export function XyPadInput() {
const pad = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const target = useRef({ x: 0.62, y: 0.35 });
const [val, setVal] = useState({ x: 0.62, y: 0.35 });
const drag = useRef(false);
useEffect(() => {
const el = canvas.current!;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = W * dpr;
el.height = H * dpr;
const ctx = el.getContext('2d')!;
ctx.scale(dpr, dpr);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const p = { x: target.current.x * W, y: (1 - target.current.y) * H };
const trail: { x: number; y: number; t: number }[] = [];
let raf = 0;
const tick = (now: number) => {
const tx = target.current.x * W;
const ty = (1 - target.current.y) * H;
// The puck follows with a little lag and leaves a fading trail.
const k = still ? 1 : 0.3;
p.x += (tx - p.x) * k;
p.y += (ty - p.y) * k;
trail.push({ x: p.x, y: p.y, t: now });
while (trail.length && now - trail[0].t > 450) trail.shift();
const [dim, ink] = [...probe.current!.children].map((c) => getComputedStyle(c).color);
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = dim;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(p.x, 0);
ctx.lineTo(p.x, H);
ctx.moveTo(0, p.y);
ctx.lineTo(W, p.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ink;
trail.forEach((q, i) => {
ctx.globalAlpha = (i / trail.length) * 0.35;
ctx.beginPath();
ctx.arc(q.x, q.y, 2 + (i / trail.length) * 4, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 0.18;
ctx.beginPath();
ctx.arc(p.x, p.y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, Math.PI * 2);
ctx.fill();
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const set = (x: number, y: number) => {
const v = { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
target.current = v;
setVal(v);
};
const fromPointer = (e: React.PointerEvent) => {
const r = pad.current!.getBoundingClientRect();
set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
};
const cutoff = Math.round(200 * Math.pow(100, val.x));
const res = val.y.toFixed(2);
return (
<>
<style>
{css}
</style>
<div className="field-xy-pad">
<span ref={probe} className="field-xy-pad__probe" aria-hidden="true">
<i />
<i />
</span>
<div className="field-xy-pad__label" id="field-xy-pad-label">
Filter
<b aria-hidden="true">
{cutoff >= 1000 ? `${(cutoff / 1000).toFixed(1)} kHz` : `${cutoff} Hz`} · Q {res}
</b>
</div>
<div
ref={pad}
className="field-xy-pad__pad"
role="slider"
tabIndex={0}
aria-labelledby="field-xy-pad-label"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(val.x * 100)}
aria-valuetext={`Cutoff ${cutoff} hertz, resonance ${res}. Arrow keys move; up and down set resonance.`}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = true;
fromPointer(e);
}}
onPointerMove={(e) => drag.current && fromPointer(e)}
onPointerUp={() => (drag.current = false)}
onKeyDown={(e) => {
const d = e.shiftKey ? 0.1 : 0.02;
const moves: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] };
const m = moves[e.key];
if (!m) return;
e.preventDefault();
set(val.x + m[0], val.y + m[1]);
}}
>
<canvas ref={canvas} aria-hidden="true" />
</div>
<div className="field-xy-pad__axis" aria-hidden="true">
<span>Cutoff →</span>
<span>↑ Resonance</span>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldXyPad(el) on the mounted element instead.
<div class="field-xy-pad">
<span class="field-xy-pad__probe" aria-hidden="true">
<i></i>
<i></i>
</span>
<div class="field-xy-pad__label" id="field-xy-pad-label">Filter<b aria-hidden="true">3.5 kHz · Q 0.35</b></div>
<div class="field-xy-pad__pad" role="slider" tabindex="0" aria-labelledby="field-xy-pad-label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="62" aria-valuetext="Cutoff 3476 hertz, resonance 0.35. Arrow keys move; up and down set resonance.">
<canvas aria-hidden="true"></canvas>
</div>
<div class="field-xy-pad__axis" aria-hidden="true">
<span>Cutoff →</span>
<span>↑ Resonance</span>
</div>
</div>.field-xy-pad { user-select: none; -webkit-user-select: none; width: 230px; }
.field-xy-pad__label { display: flex; justify-content: space-between; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-xy-pad__label b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; font-variant-numeric: tabular-nums; }
.field-xy-pad__pad {
position: relative;
height: 130px;
border-radius: 12px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 26px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 23px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
overflow: hidden;
cursor: crosshair;
touch-action: none;
outline: none;
}
.field-xy-pad__pad:focus-visible { box-shadow: inset 0 0 0 2px light-dark(#5f626a, #a7abb2); }
.field-xy-pad__pad canvas { position: absolute; inset: 0; width: 230px; height: 130px; }
.field-xy-pad__axis { display: flex; justify-content: space-between; margin-top: 5px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; }
.field-xy-pad__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.field-xy-pad__probe i:nth-child(1) { color: light-dark(#6d7179, #7d8087); }
.field-xy-pad__probe i:nth-child(2) { color: light-dark(#373a40, #f1f2f3); }function initFieldXyPad(root) {
var pad = root.querySelector('.field-xy-pad__pad');
var canvas = root.querySelector('canvas');
var probe = root.querySelector('.field-xy-pad__probe');
var read = root.querySelector('.field-xy-pad__label b');
if (!pad || !canvas || !probe || !read) return;
var W = 230;
var H = 130;
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = W * dpr;
canvas.height = H * dpr;
var ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var val = { x: 0.62, y: 0.35 };
var p = { x: val.x * W, y: (1 - val.y) * H };
var trail = [];
var drag = false;
var raf = 0;
function tick(now) {
// The puck follows with a little lag and leaves a fading trail.
var k = still ? 1 : 0.3;
p.x += (val.x * W - p.x) * k;
p.y += ((1 - val.y) * H - p.y) * k;
trail.push({ x: p.x, y: p.y, t: now });
while (trail.length && now - trail[0].t > 450) trail.shift();
var dim = getComputedStyle(probe.children[0]).color;
var ink = getComputedStyle(probe.children[1]).color;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = dim;
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(p.x, 0);
ctx.lineTo(p.x, H);
ctx.moveTo(0, p.y);
ctx.lineTo(W, p.y);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ink;
trail.forEach(function (q, i) {
ctx.globalAlpha = (i / trail.length) * 0.35;
ctx.beginPath();
ctx.arc(q.x, q.y, 2 + (i / trail.length) * 4, 0, Math.PI * 2);
ctx.fill();
});
ctx.globalAlpha = 0.18;
ctx.beginPath();
ctx.arc(p.x, p.y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(p.x, p.y, 7, 0, Math.PI * 2);
ctx.fill();
raf = requestAnimationFrame(tick);
}
function set(x, y) {
val = { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
var cutoff = Math.round(200 * Math.pow(100, val.x));
var res = val.y.toFixed(2);
read.textContent = (cutoff >= 1000 ? (cutoff / 1000).toFixed(1) + ' kHz' : cutoff + ' Hz') + ' · Q ' + res;
pad.setAttribute('aria-valuenow', String(Math.round(val.x * 100)));
pad.setAttribute('aria-valuetext', 'Cutoff ' + cutoff + ' hertz, resonance ' + res + '. Arrow keys move; up and down set resonance.');
}
function fromPointer(e) {
var r = pad.getBoundingClientRect();
set((e.clientX - r.left) / r.width, 1 - (e.clientY - r.top) / r.height);
}
function down(e) {
pad.setPointerCapture(e.pointerId);
drag = true;
fromPointer(e);
}
function move(e) { if (drag) fromPointer(e); }
function up() { drag = false; }
function key(e) {
var d = e.shiftKey ? 0.1 : 0.02;
var m = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[e.key];
if (!m) return;
e.preventDefault();
set(val.x + m[0], val.y + m[1]);
}
pad.addEventListener('pointerdown', down);
pad.addEventListener('pointermove', move);
pad.addEventListener('pointerup', up);
pad.addEventListener('keydown', key);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
pad.removeEventListener('pointerdown', down);
pad.removeEventListener('pointermove', move);
pad.removeEventListener('pointerup', up);
pad.removeEventListener('keydown', key);
};
}
document.querySelectorAll('.field-xy-pad').forEach(initFieldXyPad);Goal: Create a "XY Pad" input.
Visual behavior: Two values on one pad, like a synth filter: drag the puck and it follows with a little lag, leaving a fading trail, with crosshairs through it and both values read out above.
Technique:
- The pad is a gridded box with a canvas over it; the target point comes from the pointer and the drawn puck eases 30% of the way each frame.
- Recent positions from the last 450ms are kept and drawn as growing, fading dots, which makes the trail; dashed crosshairs run through the puck.
- X maps exponentially to a cutoff from 200 Hz to 20 kHz and Y to resonance; the canvas reads its palette from probe elements.
Accessibility: The pad is role="slider" with an aria-valuetext naming both values; arrow keys move it (Shift for bigger steps). Under prefers-reduced-motion the puck jumps without a trail.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "XY Pad" input.
Visual behavior: Two values on one pad, like a synth filter: drag the puck and it follows with a little lag, leaving a fading trail, with crosshairs through it and both values read out above.
Technique:
- The pad is a gridded box with a canvas over it; the target point comes from the pointer and the drawn puck eases 30% of the way each frame.
- Recent positions from the last 450ms are kept and drawn as growing, fading dots, which makes the trail; dashed crosshairs run through the puck.
- X maps exponentially to a cutoff from 200 Hz to 20 kHz and Y to resonance; the canvas reads its palette from probe elements.
Accessibility: The pad is role="slider" with an aria-valuetext naming both values; arrow keys move it (Shift for bigger steps). Under prefers-reduced-motion the puck jumps without a trail.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Two values on one pad, like a synth filter: drag the puck and it follows with a little lag, leaving a fading trail, with crosshairs through it and both values read out above.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, mouse tracking, spring
- Techniques
- raf, event position, mouse tracking
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The pad is role="slider" with an aria-valuetext naming both values; arrow keys move it (Shift for bigger steps). Under prefers-reduced-motion the puck jumps without a trail.