# XY Pad

> A two-dimensional control for two parameters at once, with pointer, wheel and keyboard input.

```tsx
"use client";

import { useState } from "react";
import { XYPad } from "@/components/audio-ui/xypad";

export function XYPadLiveValueDemo() {
  const [value, setValue] = useState({ x: 50, y: 50 });

  return (
    <div className="flex flex-col items-center gap-2">
      <XYPad
        aria-label="Position"
        className="aspect-square"
        maxX={100}
        maxY={100}
        minX={0}
        minY={0}
        onValueChange={setValue}
        value={value}
      />
      <output
        aria-live="polite"
        className="font-mono text-foreground text-sm tabular-nums"
      >
        X: {value.x} - Y: {value.y}
      </output>
    </div>
  );
}
```

Controls two values at once, such as filter cutoff and resonance, pan and mix, or two macros. The registry component wraps the `XYPad` primitive from `@audio-ui/react` and adds a grid, a crosshair, a cursor and a value display.

## Overview

- **Input:** press or drag on the pad, or use the mouse wheel, a trackpad or the keyboard, on both axes.
- **Axes:** X and Y have their own range and step (`minX`, `maxX`, `stepX`; `minY`, `maxY`, `stepY`).
- **Display:** grid lines, a crosshair, a cursor and an optional value display. The position is exposed as the CSS variables `--xy-pad-x` (from the left) and `--xy-pad-y` (from the top).
- **Accessibility:** the cursor holds one native `<input type="range">` per axis. Both carry the accessible name (`aria-label` becomes "… X" and "… Y", or set each with `getAriaLabel` on the primitive's `Thumb`). The X input takes focus, and the arrow keys move both axes.

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/xypad
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react class-variance-authority
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/xypad.tsx"
"use client";

import { XYPad as XYPadPrimitive } from "@audio-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";

const xypadVariants = cva("", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "h-48",
      lg: "h-64",
      sm: "h-40",
      xl: "h-96",
    },
  },
});

interface XYPadProps
  extends XYPadPrimitive.Root.Props,
    VariantProps<typeof xypadVariants> {
  /** Takes the height left in a column, from its size up: the pad gives back space a taller neighbour leaves. */
  fill?: boolean;
  formatValue?: (value: { x: number; y: number }) => React.ReactNode;
  valueDisplay?: "visible" | "hidden";
}

const GRID_LINE_POSITIONS = [20, 40, 60, 80];

export function XYPad({
  "aria-describedby": ariaDescribedBy,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  className,
  fill = false,
  size,
  formatValue,
  valueDisplay = "visible",
  ...props
}: XYPadProps) {
  const valueChildren: XYPadPrimitive.Value.Props["children"] = formatValue
    ? (_formattedX, _formattedY, value) => formatValue(value)
    : undefined;

  return (
    <XYPadPrimitive.Root
      className={cn(
        "data-disabled:cursor-not-allowed",
        fill && "flex grow flex-col"
      )}
      {...props}
    >
      <XYPadPrimitive.Control
        className={cn(
          "rounded-xl border border-transparent bg-card ring-1 ring-foreground/10 transition-[color,border-color,box-shadow,opacity] duration-150 ease-out hover:ring-3 hover:ring-ring/50 has-focus-visible:ring-3 has-focus-visible:ring-ring/50 active:ring-3 active:ring-ring/50 has-focus-visible:border-ring active:border-ring motion-reduce:transition-none relative block w-full cursor-crosshair touch-none select-none overflow-hidden",
          "data-disabled:pointer-events-none data-disabled:opacity-50",
          xypadVariants({ size }),
          fill && "grow",
          className
        )}
        data-slot="xypad"
      >
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0"
        >
          {GRID_LINE_POSITIONS.map((position) => (
            <div
              className="bg-border opacity-30 absolute top-0 bottom-0 w-px"
              key={`xypad-grid-v-${position}`}
              style={{ left: `${position}%` }}
            />
          ))}
          {GRID_LINE_POSITIONS.map((position) => (
            <div
              className="bg-border opacity-30 absolute right-0 left-0 h-px"
              key={`xypad-grid-h-${position}`}
              style={{ top: `${position}%` }}
            />
          ))}
        </div>

        <div
          aria-hidden="true"
          className="bg-primary opacity-50 pointer-events-none absolute top-0 bottom-0 left-(--xy-pad-x) w-px"
        />
        <div
          aria-hidden="true"
          className="bg-primary opacity-50 pointer-events-none absolute top-(--xy-pad-y) right-0 left-0 h-px"
        />

        <XYPadPrimitive.Thumb
          aria-describedby={ariaDescribedBy}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          className="pointer-events-none absolute size-5"
          data-slot="xypad-thumb"
        >
          <div className="rounded-full bg-primary opacity-20 blur-sm absolute inset-0 transition-opacity duration-200 motion-reduce:transition-none" />
          <div className="rounded-[min(var(--radius-md),4px)] bg-primary shadow-sm absolute inset-1" />
          <div className="rounded-full bg-primary-foreground opacity-30 absolute inset-2" />
        </XYPadPrimitive.Thumb>

        {valueDisplay === "visible" ? (
          <XYPadPrimitive.Value className="rounded-[min(var(--radius-md),10px)] bg-background/90 px-2 py-1 font-mono text-muted-foreground text-xs ring-1 ring-foreground/10 backdrop-blur-md absolute top-2.5 right-2.5 z-10">
            {valueChildren}
          </XYPadPrimitive.Value>
        ) : null}
      </XYPadPrimitive.Control>
    </XYPadPrimitive.Root>
  );
}
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { XYPad } from "@audio-ui/react";

<XYPad.Root>
  <XYPad.Label />
  <XYPad.Control>
    <XYPad.Thumb />
  </XYPad.Control>
  <XYPad.Value />
</XYPad.Root>
```

## Usage

```tsx
import { XYPad } from "@/components/audio-ui/xypad";
```

**Uncontrolled:** set only the initial value.

```tsx
<XYPad
  defaultValue={{ x: 0, y: 0 }}
  maxX={100}
  maxY={1}
  minX={-100}
  minY={-1}
  stepX={1}
  stepY={0.01}
/>
```

**Controlled:** drive the value from state.

```tsx
const [position, setPosition] = useState({ x: 50, y: 50 });

<XYPad
  maxX={100}
  maxY={100}
  minX={0}
  minY={0}
  onValueChange={setPosition}
  onValueCommitted={(value, details) => console.log("Committed:", value, details.reason)}
  value={position}
/>
```

## Interaction

### Pointer

**Behavior**

| Gesture | Effect |
| --- | --- |
| Press / drag on pad | A press jumps the value to the pointer; a drag follows it. Reported as `eventDetails.reason` `"press"` then `"drag"`. |
| Clamping | Values always clamp to the configured axis ranges and snap to `stepX` / `stepY`. |
| Commit event | `onValueCommitted` fires when a gesture that changed the value ends. |
| Lagging value | While dragging, the cursor follows the pointer even if a controlled `value` updates late. Reject a value with `eventDetails.cancel()`. |

### Keyboard

On the focused pad:

**Keys**

| Key | Effect |
| --- | --- |
| `ArrowLeft` / `ArrowRight` | Decrease / increase X by `stepX` |
| `ArrowUp` / `ArrowDown` | Increase / decrease Y by `stepY` |
| `Shift` + arrow | Move by 10 steps |
| `PageUp` / `PageDown` | Increase / decrease Y by `stepY × 10` |
| `Home` / `End` | X to `minX` / `maxX` |

### Wheel

Wheel and trackpad input over the pad moves the point by the scrolled distance: scrolling up raises Y, scrolling right raises X. Reported as `eventDetails.reason === "wheel"`.

## API reference

### Root

Groups all parts of the pad and owns its two-dimensional value. Exposes the value position as CSS variables for crosshairs and guides: `--xy-pad-x` (from the left) and `--xy-pad-y` (from the top), as percentages. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultValue` | `Point` |  | Uncontrolled starting value. Defaults to the center of the pad. |
| `disabled` | `boolean` |  |  |
| `format` | `Intl.NumberFormatOptions` |  | Formats each axis for `XYPad.Value` and the inputs' `aria-valuetext`. |
| `locale` | `Intl.LocalesArgument` |  |  |
| `maxX` | `number` | `100` |  |
| `maxY` | `number` | `100` |  |
| `minX` | `number` | `0` |  |
| `minY` | `number` | `0` |  |
| `onValueChange` | `(value: Point, eventDetails: ChangeEventDetails) => void` |  | Fires on every change: press, drag, keyboard, wheel and input changes. |
| `onValueCommitted` | `(value: Point, eventDetails: CommitEventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `stepX` | `number` | `1` |  |
| `name` | `string` |  | Names the pad for a form: its two inputs submit as `name.x` and `name.y`. |
| `stepY` | `number` | `1` |  |
| `value` | `Point` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging on the pad. |

### Control

The pad surface: a press jumps the value to the pointer and a drag follows it; the mouse wheel / trackpad moves it too. Sets `touch-action: none` so touch drags don't scroll the page. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging on the pad. |

### Thumb

Marks the value on the pad (`left`/`top` as percentages, centered with `translate`). Holds two native `<input type="range">`s, one per axis: the X input takes focus, both carry the accessible name, and the arrow keys move both axes. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaLabel` | `(axis: Axis) => string` |  | Names each axis input. Defaults to `aria-label` followed by "X" / "Y". |
| `getAriaValueText` | `(formattedValue: string, value: number, axis: Axis) => string` |  | Returns an axis input's `aria-valuetext`. Defaults to the formatted value. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging on the pad. |

### Value

The formatted value (`format`/`locale` on Root), `"x, y"` by default. Renders a `<output>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `\| React.ReactNode \| ((formattedX: string, formattedY: string, value: Point) => React.ReactNode)` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging on the pad. |

### Label

Accessible label, associated with both axis inputs. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging on the pad. |

### Registry component

`XYPad` accepts style props (`size`, `formatValue`, `valueDisplay`, `className`) plus primitive props from `XYPadPrimitive.Root.Props`. `aria-label`, `aria-labelledby`, and `aria-describedby` are forwarded to the axis inputs.

#### Value and state

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `{ x: number; y: number }` |  | Controlled value. |
| `defaultValue` | `{ x: number; y: number }` | `center` | Uncontrolled starting value. Defaults to the center of the pad. |
| `minX / maxX` | `number` | `0 / 100` | X range. |
| `minY / maxY` | `number` | `0 / 100` | Y range. |
| `stepX / stepY` | `number` | `1` | Step per axis. |
| `disabled` | `boolean` | `false` | Disables pointer, wheel, and keyboard. |
| `format` | `Intl.NumberFormatOptions` |  | Formats each axis for `XYPad.Value` and the inputs' `aria-valuetext`. |
| `locale` | `Intl.LocalesArgument` | `runtime` | Locale used by `format`. |
| `onValueChange` | `(value, eventDetails) => void` |  | Fires on every change: press, drag, keyboard, wheel and input changes. |
| `onValueCommitted` | `(value, eventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `aria-label` | `string` |  | Accessible name for the pad's inputs. |
| `aria-labelledby` | `string` |  | ID of a labeling element. |

#### Layout and display

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg" \| "xl"` | `"default"` | Size variant (affects height). |
| `formatValue` | `(value: { x: number; y: number }) => React.ReactNode` |  | Custom formatter for the value display. |
| `valueDisplay` | `"visible" \| "hidden"` | `"visible"` | Use `"hidden"` to hide the overlay. |
| `className` | `string` |  | Classes for the pad surface. |

`@audio-ui/react/xypad` exposes `XYPad.Root`, `XYPad.Control` (the pad surface), `XYPad.Thumb` (positioned at the value, holds the axis inputs), `XYPad.Value` (formatted `<output>`), and `XYPad.Label`. Every part accepts a `render` prop, `className` / `style` as functions of state, and exposes `data-disabled` / `data-dragging`. Draw grids and crosshairs with your own elements and the `--xy-pad-x` / `--xy-pad-y` variables.

## Examples

### Live value readout

```tsx
"use client";

import { useState } from "react";
import { XYPad } from "@/components/audio-ui/xypad";

export function XYPadLiveValueDemo() {
  const [value, setValue] = useState({ x: 50, y: 50 });

  return (
    <div className="flex flex-col items-center gap-2">
      <XYPad
        aria-label="Position"
        className="aspect-square"
        maxX={100}
        maxY={100}
        minX={0}
        minY={0}
        onValueChange={setValue}
        value={value}
      />
      <output
        aria-live="polite"
        className="font-mono text-foreground text-sm tabular-nums"
      >
        X: {value.x} - Y: {value.y}
      </output>
    </div>
  );
}
```

### Bipolar ranges (pan and mix style)

```tsx
"use client";

import { useState } from "react";
import { XYPad } from "@/components/audio-ui/xypad";

const formatPanMix = (next: { x: number; y: number }) =>
  `Pan ${next.x} | Mix ${next.y.toFixed(2)}`;

export function XYPadBipolarRangeDemo() {
  const [value, setValue] = useState({ x: 0, y: 0 });

  return (
    <div className="flex flex-col items-center gap-2">
      <XYPad
        aria-label="Pan and mix"
        className="aspect-square"
        defaultValue={{ x: 0, y: 0 }}
        formatValue={formatPanMix}
        maxX={100}
        maxY={1}
        minX={-100}
        minY={-1}
        onValueChange={setValue}
        stepX={1}
        stepY={0.01}
        value={value}
      />
      <output
        aria-live="polite"
        className="font-mono text-foreground text-sm tabular-nums"
      >
        Pan: {value.x} - Mix: {value.y.toFixed(2)}
      </output>
    </div>
  );
}
```

### Pocket synth

An oscillator, a filter and an XY pad that plays them: drag the pad to sweep the sound.

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { PocketSynthPanel } from "@/components/audio-ui/devices/pocket-synth/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import {
  type PocketSynthNote,
  type PocketSynthState,
  pocketSynthDevice,
} from "@/components/audio-ui/devices/pocket-synth/device";
import {
  createPocketSynthEngine,
  type PocketSynthEngine,
} from "@/components/audio-ui/devices/pocket-synth/engine";
import { webAudio } from "@/lib/web-audio";

/**
 * A synth you can plug in: its state, a panel that draws and plays it and an
 * engine that makes the sound, each its own file and none knowing the others.
 * Press the pad and move: pitch goes across, the filter cutoff up.
 */
export function BlockPocketSynth() {
  const [state, setState] = useState<PocketSynthState>(() =>
    pocketSynthDevice.defaults()
  );
  const master = useMasterBus(1);
  const [engine, setEngine] = useState<PocketSynthEngine | null>(null);
  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && master)) {
      return;
    }
    const synth = createPocketSynthEngine(ctx, stateRef.current);
    synth.output.connect(master);
    setEngine(synth);
    return () => {
      synth.dispose();
      setEngine(null);
    };
  }, [master]);

  // The engine follows the state, whoever changed it: a button or a call.
  useEffect(() => {
    engine?.update(state);
  }, [engine, state]);

  const play = useCallback(
    (note: PocketSynthNote) => engine?.noteOn(note),
    [engine]
  );
  const release = useCallback(() => engine?.noteOff(), [engine]);

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <PocketSynthPanel
        onNoteOff={release}
        onNoteOn={play}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```

### Wave shaper

Distortion, tone and detune on a synth, with the XY pad and knobs side by side.

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { WaveShaperPanel } from "@/components/audio-ui/devices/wave-shaper/panel";
import { useMasterBus } from "@/hooks/use-master-bus";
import {
  type WaveShaperNote,
  type WaveShaperState,
  waveShaperDevice,
} from "@/components/audio-ui/devices/wave-shaper/device";
import {
  createWaveShaperEngine,
  type WaveShaperEngine,
} from "@/components/audio-ui/devices/wave-shaper/engine";
import { webAudio } from "@/lib/web-audio";

/**
 * A wave shaper synth you can plug in: its state, a panel that draws and plays
 * it and an engine that makes the sound, each its own file and none knowing
 * the others. Press the pad and move: pitch goes across, the resonance up.
 */
export function BlockWaveShaper() {
  const [state, setState] = useState<WaveShaperState>(() =>
    waveShaperDevice.defaults()
  );
  const master = useMasterBus(1);
  const [engine, setEngine] = useState<WaveShaperEngine | null>(null);
  const stateRef = useRef(state);
  stateRef.current = state;

  useEffect(() => {
    const ctx = webAudio.getContext();
    if (!(ctx && master)) {
      return;
    }
    const synth = createWaveShaperEngine(ctx, stateRef.current);
    synth.output.connect(master);
    setEngine(synth);
    return () => {
      synth.dispose();
      setEngine(null);
    };
  }, [master]);

  // The engine follows the state, whoever changed it: a button or a knob.
  useEffect(() => {
    engine?.update(state);
  }, [engine, state]);

  const play = useCallback(
    (note: WaveShaperNote) => engine?.noteOn(note),
    [engine]
  );
  const release = useCallback(() => engine?.noteOff(), [engine]);

  return (
    <div className="rounded-xl h-full w-full min-w-0">
      <WaveShaperPanel
        onNoteOff={release}
        onNoteOn={play}
        onValueChange={setState}
        value={state}
      />
    </div>
  );
}
```
