Tier 3: bubbles, animation, wrap mode, XYPad, AlphaDisplay, PushButton

- valueBubble/bubbleFormat: floating readout above knobs while dragging
  (Knob-based skins + ImageKnob)
- animateChanges: rAF ease-out tween of the pointer on programmatic
  value changes; user gestures snap; prefers-reduced-motion disables
- wrap: endless encoder mode — values and relative drags roll around
  min↔max; pairs with angleOffset={0} angleRange={360}
- XYPad: two-parameter pad with absolute drag, arrow keys (Shift
  coarse), Escape cancel, double-click reset, grid/crosshair, focus
  ring, hidden form inputs, change meta
- AlphaDisplay: fourteen-segment alphanumeric LED (A-Z, 0-9, symbols,
  decimal points, chars padding/align, glow/skew)
- PushButton: studio panel button with LED strip — toggle or momentary,
  controlled/uncontrolled, aria-pressed, hidden form input
- Docs: five new gallery cards incl. animated preset demo, API rows,
  README
This commit is contained in:
Dreamodus 2026-07-12 17:47:20 -07:00
parent 0aee48b749
commit e9e52e43ed
12 changed files with 936 additions and 8 deletions

View file

@ -22,6 +22,10 @@ export interface KnobProps extends KnobCoreProps {
parseValue?: (text: string) => number | null;
/** Render a hidden form input carrying the current value. */
name?: string;
/** Show a floating value bubble above the knob while dragging. */
valueBubble?: boolean;
/** Formatter for the bubble text (defaults to the trimmed value). */
bubbleFormat?: (value: number) => string;
className?: string;
style?: React.CSSProperties;
/** SVG children (Arc, Pointer, Ticks, ...) or a render function receiving the live state. */
@ -42,6 +46,8 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
editable = false,
parseValue,
name,
valueBubble = false,
bubbleFormat,
className,
style,
children,
@ -128,7 +134,36 @@ export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(function Knob(
/>
)}
{name && <input type="hidden" name={name} value={knob.value} readOnly />}
{valueBubble && knob.isDragging && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(8, 9, 12, 0.92)',
border: '1px solid rgba(255,255,255,0.16)',
borderRadius: 6,
padding: '2px 8px',
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.text,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 2,
}}
>
{(bubbleFormat ?? trimValue(knob.decimals))(knob.value)}
</div>
)}
</div>
</KnobContextProvider>
);
});
const trimValue =
(decimals: number) =>
(v: number): string => {
const s = v.toFixed(decimals);
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
};

View file

@ -67,6 +67,19 @@ export interface KnobCoreProps {
detents?: readonly number[];
/** Detent capture radius as a fraction of travel. Default: 0.025. */
detentSize?: number;
/**
* Endless encoder: values wrap around minmax instead of clamping (phase,
* LFO offset). Pair with `angleOffset={0} angleRange={360}` for a full-turn
* dial. Default: false.
*/
wrap?: boolean;
/**
* Tween the pointer when the value changes programmatically (preset loads,
* automation) instead of teleporting. `true` = 160 ms ease-out, or pass
* `{ duration }`. User gestures are never animated, and
* `prefers-reduced-motion` disables it. Default: false.
*/
animateChanges?: boolean | { duration?: number };
/** Drag behaviour. Default: 'rotary'. */
interaction?: InteractionMode;
/** How rotary drags engage: 'absolute' (default), 'relative', or 'pickup'. */

View file

@ -0,0 +1,240 @@
import * as React from 'react';
// Fourteen-segment geometry in a local 10 x 18 digit cell.
// Outer ring: a top, b top-right, c bottom-right, d bottom, e bottom-left,
// f top-left. Middle bar: g1 left, g2 right. Center verticals: i top, l
// bottom. Diagonals: h TL, j TR, k BL, m BR.
const W = 10;
const H = 18;
const GAP = 3.4;
const HALF = 1.05;
const INSET = 0.45;
const hSeg = (y: number, x1: number, x2: number): string => {
const a = x1 + INSET;
const b = x2 - INSET;
return `${a},${y} ${a + HALF},${y - HALF} ${b - HALF},${y - HALF} ${b},${y} ${b - HALF},${y + HALF} ${a + HALF},${y + HALF}`;
};
const vSeg = (x: number, y1: number, y2: number): string => {
const a = y1 + INSET;
const b = y2 - INSET;
return `${x},${a} ${x + HALF},${a + HALF} ${x + HALF},${b - HALF} ${x},${b} ${x - HALF},${b - HALF} ${x - HALF},${a + HALF}`;
};
type Seg14 =
| 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
| 'g1' | 'g2' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm';
const POLY: Partial<Record<Seg14, string>> = {
a: hSeg(1, 1, 9),
d: hSeg(17, 1, 9),
g1: hSeg(9, 1, 5),
g2: hSeg(9, 5, 9),
f: vSeg(1, 1, 9),
b: vSeg(9, 1, 9),
e: vSeg(1, 9, 17),
c: vSeg(9, 9, 17),
i: vSeg(5, 1, 9),
l: vSeg(5, 9, 17),
};
/** Diagonals render as thick lines: [x1, y1, x2, y2]. */
const DIAG: Partial<Record<Seg14, [number, number, number, number]>> = {
h: [2.2, 2.7, 4.1, 7.2],
j: [7.8, 2.7, 5.9, 7.2],
k: [4.1, 10.8, 2.2, 15.3],
m: [5.9, 10.8, 7.8, 15.3],
};
const CHARS: Record<string, Seg14[]> = {
'0': ['a', 'b', 'c', 'd', 'e', 'f'],
'1': ['b', 'c'],
'2': ['a', 'b', 'g1', 'g2', 'e', 'd'],
'3': ['a', 'b', 'c', 'd', 'g1', 'g2'],
'4': ['f', 'g1', 'g2', 'b', 'c'],
'5': ['a', 'f', 'g1', 'g2', 'c', 'd'],
'6': ['a', 'f', 'g1', 'g2', 'e', 'c', 'd'],
'7': ['a', 'b', 'c'],
'8': ['a', 'b', 'c', 'd', 'e', 'f', 'g1', 'g2'],
'9': ['a', 'b', 'c', 'd', 'f', 'g1', 'g2'],
A: ['a', 'b', 'c', 'e', 'f', 'g1', 'g2'],
B: ['a', 'b', 'c', 'd', 'i', 'l', 'g2'],
C: ['a', 'd', 'e', 'f'],
D: ['a', 'b', 'c', 'd', 'i', 'l'],
E: ['a', 'd', 'e', 'f', 'g1', 'g2'],
F: ['a', 'e', 'f', 'g1', 'g2'],
G: ['a', 'c', 'd', 'e', 'f', 'g2'],
H: ['b', 'c', 'e', 'f', 'g1', 'g2'],
I: ['a', 'd', 'i', 'l'],
J: ['b', 'c', 'd', 'e'],
K: ['e', 'f', 'g1', 'j', 'm'],
L: ['d', 'e', 'f'],
M: ['b', 'c', 'e', 'f', 'h', 'j'],
N: ['b', 'c', 'e', 'f', 'h', 'm'],
O: ['a', 'b', 'c', 'd', 'e', 'f'],
P: ['a', 'b', 'e', 'f', 'g1', 'g2'],
Q: ['a', 'b', 'c', 'd', 'e', 'f', 'm'],
R: ['a', 'b', 'e', 'f', 'g1', 'g2', 'm'],
S: ['a', 'c', 'd', 'f', 'g1', 'g2'],
T: ['a', 'i', 'l'],
U: ['b', 'c', 'd', 'e', 'f'],
V: ['e', 'f', 'j', 'k'],
W: ['b', 'c', 'e', 'f', 'k', 'm'],
X: ['h', 'j', 'k', 'm'],
Y: ['h', 'j', 'l'],
Z: ['a', 'd', 'j', 'k'],
'-': ['g1', 'g2'],
'_': ['d'],
'=': ['g1', 'g2', 'd'],
'+': ['g1', 'g2', 'i', 'l'],
'*': ['g1', 'g2', 'h', 'i', 'j', 'k', 'l', 'm'],
'/': ['j', 'k'],
'\\': ['h', 'm'],
'?': ['a', 'b', 'g2', 'l'],
' ': [],
};
const ALL_SEGMENTS = Object.keys({ ...POLY, ...DIAG }) as Seg14[];
export interface AlphaDisplayProps {
/** Text: AZ, 09, space, - _ = + * / \ ? and '.' (attaches to the previous cell). */
value: string;
/** Fixed number of character cells; the text is padded/cropped to fit. */
chars?: number;
align?: 'left' | 'right';
/** Character height in px. Default: 22. */
height?: number;
color?: string;
/** Panel background. Set to 'none' to disable. */
background?: string;
/** Opacity of unlit segments. Default: 0.08. */
ghostOpacity?: number;
/** Italic skew in degrees. Default: 6. */
skew?: number;
/** LED glow strength (0 disables). Default: 1.4. */
glow?: number;
padding?: number;
className?: string;
style?: React.CSSProperties;
}
/**
* Fourteen-segment alphanumeric LED display preset names, modes, messages.
* The alphanumeric sibling of `SegmentDisplay`.
*/
export const AlphaDisplay: React.FC<AlphaDisplayProps> = ({
value,
chars,
align = 'left',
height = 22,
color = '#ffb84d',
background = '#0a0d0c',
ghostOpacity = 0.08,
skew = 6,
glow = 1.4,
padding = 6,
className,
style,
}) => {
const filterId = React.useId();
let text = value.toUpperCase();
if (chars !== undefined) {
const cellCount = text.replace(/\./g, '').length;
if (cellCount < chars) {
const pad = ' '.repeat(chars - cellCount);
text = align === 'right' ? pad + text : text + pad;
} else if (cellCount > chars) {
let kept = 0;
let out = '';
for (const ch of text) {
if (ch !== '.' && kept === chars) break;
if (ch !== '.') kept++;
out += ch;
}
text = out;
}
}
const cells: React.ReactNode[] = [];
let x = 0;
let key = 0;
for (const ch of text) {
if (ch === '.') {
cells.push(
<circle key={key++} cx={x - GAP / 2 + 0.2} cy={H - 1.2} r={1.15} fill={color} />,
);
continue;
}
const lit = new Set(CHARS[ch] ?? []);
const parts: React.ReactNode[] = [];
for (const seg of ALL_SEGMENTS) {
const on = lit.has(seg);
if (!on && ghostOpacity <= 0) continue;
const opacity = on ? 1 : ghostOpacity;
const poly = POLY[seg];
if (poly) {
parts.push(<polygon key={seg} points={poly} fill={color} opacity={opacity} />);
} else {
const [x1, y1, x2, y2] = DIAG[seg]!;
parts.push(
<line
key={seg}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={color}
strokeWidth={1.5}
strokeLinecap="butt"
opacity={opacity}
/>,
);
}
}
cells.push(
<g key={key++} transform={`translate(${x} 0)`}>
{parts}
</g>,
);
x += W + GAP;
}
const contentW = Math.max(x - GAP, 0);
const scale = height / H;
const w = contentW * scale + padding * 2 + height * 0.15;
const h = height + padding * 2;
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
className={className}
style={style}
role="img"
aria-label={value}
>
{glow > 0 && (
<defs>
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation={glow} result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
)}
{background !== 'none' && <rect x={0} y={0} width={w} height={h} rx={4} fill={background} />}
<g
transform={`translate(${padding + height * 0.12} ${padding}) scale(${scale})`}
filter={glow > 0 ? `url(#${filterId})` : undefined}
>
{skew ? <g transform={`skewX(${-Math.abs(skew)})`}>{cells}</g> : cells}
</g>
</svg>
);
};

View file

@ -92,6 +92,8 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
origin,
detents,
detentSize = 0.025,
wrap = false,
animateChanges = false,
fineMultiplier = 0.1,
angleOffset = 225,
angleRange = 270,
@ -116,10 +118,16 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
props.doubleClickReset ?? props.defaultValue !== undefined;
const interactive = !disabled && !readOnly;
/** Clamp, snap and round a raw value into an emittable one. */
/** Clamp (or wrap), snap and round a raw value into an emittable one. */
const constrain = React.useCallback(
(raw: number): number => {
let v = clamp(raw, min, max);
let v: number;
if (wrap && max > min) {
const span = max - min;
v = min + ((((raw - min) % span) + span) % span);
} else {
v = clamp(raw, min, max);
}
if (values && values.length > 0) {
v = findClosest(values, v);
} else if (steps && steps > 1) {
@ -131,7 +139,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
}
return roundTo(v, decimals);
},
[min, max, step, values, steps, taper, decimals],
[min, max, step, values, steps, taper, decimals, wrap],
);
const isControlled = props.value !== undefined;
@ -167,10 +175,14 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
[constrain, isControlled, onChange],
);
const wrapN = React.useCallback(
(n: number) => (wrap ? ((n % 1) + 1) % 1 : clamp(n, 0, 1)),
[wrap],
);
const setFromNormalized = React.useCallback(
(n: number, source: ChangeSource = 'api') =>
emit(taper.fromNormalized(clamp(n, 0, 1), min, max), source),
[emit, taper, min, max],
emit(taper.fromNormalized(wrapN(n), min, max), source),
[emit, taper, min, max, wrapN],
);
// Detent positions in normalized space, applied only during pointer drags.
@ -214,7 +226,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
const dn = (delta / angleRange) * (fine ? fineMultiplier : 1) * gain;
s.n = clamp(s.n + dn, 0, 1);
s.n = wrapN(s.n + dn);
setFromDrag(s.n);
}
s.prevAngle = angle;
@ -257,7 +269,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
if (interaction === 'horizontal' || interaction === 'both')
dn += (clientX - s.lastX) * scale;
// s.n stays continuous so magnetic detents release cleanly.
s.n = clamp(s.n + dn, 0, 1);
s.n = wrapN(s.n + dn);
setFromDrag(s.n);
}
}
@ -275,6 +287,7 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
trackInset,
fineMultiplier,
setFromDrag,
wrapN,
],
);
@ -530,7 +543,49 @@ export function useKnob(props: KnobCoreProps): UseKnobResult {
onChangeEndRef.current?.(target, { source: 'reset' });
}, [interactive, doubleClickReset, props.defaultValue, emit, constrain]);
const normalized = clamp(taper.toNormalized(value, min, max), 0, 1);
// -------------------------------------------------------------------------
// Programmatic-change animation: the pointer glides, the readout/ARIA value
// stays exact. User gestures snap; prefers-reduced-motion disables it.
// -------------------------------------------------------------------------
const animMs =
animateChanges === true
? 160
: typeof animateChanges === 'object'
? (animateChanges.duration ?? 160)
: 0;
const [displayValue, setDisplayValue] = React.useState(value);
const displayValueRef = React.useRef(displayValue);
displayValueRef.current = displayValue;
const rafRef = React.useRef<number>();
React.useEffect(() => {
if (!animMs) return;
const reduced =
typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const gestureActive =
session.current !== null || wheelActive.current || keyAdjusted.current;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (reduced || gestureActive || displayValueRef.current === value) {
setDisplayValue(value);
return;
}
const from = displayValueRef.current;
const t0 = now();
const tick = (t: number) => {
const p = Math.min(1, (t - t0) / animMs);
const eased = 1 - Math.pow(1 - p, 3);
setDisplayValue(p >= 1 ? value : from + (value - from) * eased);
if (p < 1) rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [value, animMs]);
const shownValue = animMs ? displayValue : value;
const normalized = clamp(taper.toNormalized(shownValue, min, max), 0, 1);
const angle = angleFromNormalized(normalized, angleOffset, angleRange);
const originNormalized =
origin === undefined

View file

@ -49,6 +49,7 @@ export { KnobValue, KnobLabel, type KnobValueProps, type KnobLabelProps } from '
// Digital
export { SegmentDisplay, type SegmentDisplayProps } from './digital/SegmentDisplay';
export { AlphaDisplay, type AlphaDisplayProps } from './digital/AlphaDisplay';
// Value editing
export { defaultParseValue, ValueInput, type ValueInputProps } from './components/ValueInput';
@ -66,4 +67,6 @@ export { Fader, type FaderProps } from './skins/Fader';
export { LEDFader, type LEDFaderProps, type LEDFaderZone } from './skins/LEDFader';
export { ImageKnob, type ImageKnobProps } from './skins/ImageKnob';
export { Meter, type MeterProps } from './skins/Meter';
export { PushButton, type PushButtonProps } from './skins/PushButton';
export { XYPad, type XYPadProps, type XYPadAxis, type XYPadValue } from './skins/XYPad';
export { GlowFilter, type GlowFilterProps } from './primitives/GlowFilter';

View file

@ -24,6 +24,9 @@ export interface ImageKnobProps extends KnobCoreProps {
/** Double-click to type an exact value. */
editable?: boolean;
parseValue?: (text: string) => number | null;
/** Show a floating value bubble above the knob while dragging. */
valueBubble?: boolean;
bubbleFormat?: (value: number) => string;
/** Extra styles for the image layer (e.g. filter, borderRadius). */
imageStyle?: React.CSSProperties;
className?: string;
@ -48,6 +51,8 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
focusRing,
editable = false,
parseValue,
valueBubble = false,
bubbleFormat,
imageStyle,
className,
style,
@ -131,6 +136,28 @@ export const ImageKnob = React.forwardRef<HTMLDivElement, ImageKnobProps>(functi
onCancel={closeEditor}
/>
)}
{valueBubble && knob.isDragging && (
<div
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(8, 9, 12, 0.92)',
border: '1px solid rgba(255,255,255,0.16)',
borderRadius: 6,
padding: '2px 8px',
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.text,
whiteSpace: 'nowrap',
pointerEvents: 'none',
zIndex: 2,
}}
>
{(bubbleFormat ?? (() => text))(knob.value)}
</div>
)}
</div>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>

View file

@ -0,0 +1,142 @@
import * as React from 'react';
import { useKnobTheme } from '../core/theme';
export interface PushButtonProps {
/** Controlled pressed state. Pair with `onChange`. */
pressed?: boolean;
defaultPressed?: boolean;
onChange?: (pressed: boolean) => void;
/** 'toggle' latches; 'momentary' is only on while held. Default: 'toggle'. */
mode?: 'toggle' | 'momentary';
/** LED / active color. */
color?: string;
/** Show the LED strip. Default: true. */
led?: boolean;
/** Button height in px. Default: 36. */
size?: number;
/** Explicit width; defaults to content. */
width?: number;
disabled?: boolean;
/** Render a hidden form input carrying "on"/"off". */
name?: string;
/** Caption, e.g. "MUTE". */
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
/** Studio panel button with an LED strip — mute/solo/bypass in matching style. */
export const PushButton = React.forwardRef<HTMLButtonElement, PushButtonProps>(
function PushButton(
{
pressed,
defaultPressed = false,
onChange,
mode = 'toggle',
color,
led = true,
size = 36,
width,
disabled = false,
name,
children,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const isControlled = pressed !== undefined;
const [internal, setInternal] = React.useState(defaultPressed);
const on = isControlled ? (pressed as boolean) : internal;
const set = (next: boolean) => {
if (next === on) return;
if (!isControlled) setInternal(next);
onChange?.(next);
};
const momentaryProps =
mode === 'momentary'
? {
onPointerDown: (e: React.PointerEvent) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(true);
},
onPointerUp: () => set(false),
onPointerCancel: () => set(false),
onKeyDown: (e: React.KeyboardEvent) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) set(true);
},
onKeyUp: (e: React.KeyboardEvent) => {
if (e.key === ' ' || e.key === 'Enter') set(false);
},
}
: { onClick: () => set(!on) };
return (
<button
ref={ref}
type="button"
disabled={disabled}
aria-pressed={on}
aria-label={aria['aria-label']}
data-pressed={on ? '' : undefined}
className={className}
{...momentaryProps}
style={{
position: 'relative',
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
height: size,
width,
minWidth: size * 1.4,
padding: '0 12px',
borderRadius: 7,
border: '1px solid rgba(0,0,0,0.7)',
background: on
? 'linear-gradient(180deg, #17181d, #232429)'
: 'linear-gradient(180deg, #35363d, #1d1e24)',
boxShadow: on
? 'inset 0 2px 5px rgba(0,0,0,0.65)'
: 'inset 0 1px 0 rgba(255,255,255,0.09), 0 2px 4px rgba(0,0,0,0.45)',
color: on ? theme.text : theme.label,
fontFamily: theme.fontUI,
fontSize: Math.max(10, size * 0.28),
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.45 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'manipulation',
transition: 'background 80ms, box-shadow 80ms, color 80ms',
...style,
}}
>
{led && (
<span
aria-hidden="true"
style={{
width: '55%',
height: Math.max(3, size * 0.09),
borderRadius: 3,
background: on ? accent : 'rgba(255,255,255,0.09)',
boxShadow: on ? `0 0 ${size * 0.22}px ${accent}` : 'inset 0 1px 1px rgba(0,0,0,0.6)',
transition: 'background 80ms, box-shadow 80ms',
}}
/>
)}
{children}
{name && <input type="hidden" name={name} value={on ? 'on' : 'off'} readOnly />}
</button>
);
},
);

View file

@ -0,0 +1,298 @@
import * as React from 'react';
import { clamp, decimalsFromStep, roundTo, snapToStep } from '../core/math';
import { useKnobTheme } from '../core/theme';
import type { ChangeMeta } from '../core/types';
export interface XYPadAxis {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
}
export interface XYPadValue {
x: number;
y: number;
}
export interface XYPadProps {
/** X axis config (left → right = min → max). */
x?: XYPadAxis;
/** Y axis config (bottom → top = min → max, audio convention). */
y?: XYPadAxis;
/** Controlled position. Pair with `onChange`. */
value?: XYPadValue;
onChange?: (value: XYPadValue, meta: ChangeMeta) => void;
onChangeStart?: (value: XYPadValue, meta: ChangeMeta) => void;
onChangeEnd?: (value: XYPadValue, meta: ChangeMeta) => void;
width?: number;
height?: number;
/** Handle / crosshair accent. */
color?: string;
faceColor?: string;
/** Grid divisions per axis (0 disables). Default: 4. */
grid?: number;
/** Show the "x · y" readout above the pad. Default: true. */
showValue?: boolean;
formatX?: (v: number) => string;
formatY?: (v: number) => string;
label?: string;
focusRing?: string | false;
disabled?: boolean;
/** Hidden form inputs for each axis. */
nameX?: string;
nameY?: string;
className?: string;
style?: React.CSSProperties;
'aria-label'?: string;
}
const trim = (v: number, decimals: number): string => {
const s = v.toFixed(decimals);
return s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s;
};
/** Two-parameter XY pad — filter cutoff/resonance, vector mixing, FX morphing. */
export const XYPad = React.forwardRef<HTMLDivElement, XYPadProps>(function XYPad(
{
x: xAxis,
y: yAxis,
value,
onChange,
onChangeStart,
onChangeEnd,
width = 200,
height = 160,
color,
faceColor = 'rgba(255,255,255,0.04)',
grid = 4,
showValue = true,
formatX,
formatY,
label,
focusRing,
disabled = false,
nameX,
nameY,
className,
style,
...aria
},
ref,
) {
const theme = useKnobTheme();
const accent = color ?? theme.accent ?? '#4cc2ff';
const ax = { min: 0, max: 100, step: 0, defaultValue: 50, ...xAxis };
const ay = { min: 0, max: 100, step: 0, defaultValue: 50, ...yAxis };
const dx = ax.step > 0 ? decimalsFromStep(ax.step) : 2;
const dy = ay.step > 0 ? decimalsFromStep(ay.step) : 2;
const conX = React.useCallback(
(v: number) =>
roundTo(clamp(ax.step > 0 ? snapToStep(v, ax.step, ax.min) : v, ax.min, ax.max), dx),
[ax.min, ax.max, ax.step, dx],
);
const conY = React.useCallback(
(v: number) =>
roundTo(clamp(ay.step > 0 ? snapToStep(v, ay.step, ay.min) : v, ay.min, ay.max), dy),
[ay.min, ay.max, ay.step, dy],
);
const isControlled = value !== undefined;
const [internal, setInternal] = React.useState<XYPadValue>({
x: conX(ax.defaultValue),
y: conY(ay.defaultValue),
});
const pos: XYPadValue = {
x: conX(isControlled ? (value as XYPadValue).x : internal.x),
y: conY(isControlled ? (value as XYPadValue).y : internal.y),
};
const posRef = React.useRef(pos);
posRef.current = pos;
const [isDragging, setIsDragging] = React.useState(false);
const [isFocusVisible, setIsFocusVisible] = React.useState(false);
const pointerFocus = React.useRef(false);
const rootRef = React.useRef<HTMLDivElement>(null);
const session = React.useRef<{ pointerId: number; rect: DOMRect; start: XYPadValue } | null>(
null,
);
const emit = (next: XYPadValue) => {
const c = { x: conX(next.x), y: conY(next.y) };
if (c.x === posRef.current.x && c.y === posRef.current.y) return;
if (!isControlled) setInternal(c);
onChange?.(c, { source: 'drag' });
};
const applyPointer = (clientX: number, clientY: number) => {
const s = session.current;
if (!s || s.rect.width <= 0 || s.rect.height <= 0) return;
const nx = clamp((clientX - s.rect.left) / s.rect.width, 0, 1);
const ny = clamp((s.rect.bottom - clientY) / s.rect.height, 0, 1);
emit({ x: ax.min + nx * (ax.max - ax.min), y: ay.min + ny * (ay.max - ay.min) });
};
const nx = ax.max === ax.min ? 0 : (pos.x - ax.min) / (ax.max - ax.min);
const ny = ay.max === ay.min ? 0 : (pos.y - ay.min) / (ay.max - ay.min);
const hx = nx * width;
const hy = (1 - ny) * height;
const gridLines: React.ReactNode[] = [];
for (let i = 1; grid > 1 && i < grid; i++) {
const gx = (i / grid) * width;
const gy = (i / grid) * height;
gridLines.push(
<line key={`v${i}`} x1={gx} y1={0} x2={gx} y2={height} stroke={theme.track} strokeWidth="1" />,
<line key={`h${i}`} x1={0} y1={gy} x2={width} y2={gy} stroke={theme.track} strokeWidth="1" />,
);
}
const ring = focusRing ?? theme.focusRing;
const text = `${formatX ? formatX(pos.x) : trim(pos.x, dx)} · ${formatY ? formatY(pos.y) : trim(pos.y, dy)}`;
return (
<div
className={className}
style={{
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
width,
...style,
}}
>
{showValue && (
<div style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
<span
style={{
fontFamily: theme.fontMono,
fontSize: 12,
color: theme.text,
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}
>
{text}
</span>
</div>
)}
<div
role="application"
aria-roledescription="X/Y pad"
aria-label={aria['aria-label']}
aria-valuetext={text}
tabIndex={disabled ? -1 : 0}
data-dragging={isDragging ? '' : undefined}
data-disabled={disabled ? '' : undefined}
ref={node => {
(rootRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
}}
onPointerDown={e => {
if (disabled || session.current || !rootRef.current) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.preventDefault();
pointerFocus.current = true;
rootRef.current.focus({ preventScroll: true });
rootRef.current.setPointerCapture(e.pointerId);
session.current = {
pointerId: e.pointerId,
rect: rootRef.current.getBoundingClientRect(),
start: posRef.current,
};
setIsDragging(true);
onChangeStart?.(posRef.current, { source: 'drag' });
applyPointer(e.clientX, e.clientY);
}}
onPointerMove={e => {
if (disabled || !session.current || e.pointerId !== session.current.pointerId) return;
applyPointer(e.clientX, e.clientY);
}}
onPointerUp={e => {
if (!session.current || e.pointerId !== session.current.pointerId) return;
session.current = null;
setIsDragging(false);
onChangeEnd?.(posRef.current, { source: 'drag' });
}}
onPointerCancel={e => {
if (!session.current || e.pointerId !== session.current.pointerId) return;
session.current = null;
setIsDragging(false);
onChangeEnd?.(posRef.current, { source: 'drag' });
}}
onKeyDown={e => {
if (disabled) return;
if (e.key === 'Escape' && session.current) {
const start = session.current.start;
session.current = null;
setIsDragging(false);
emit(start);
onChangeEnd?.(start, { source: 'drag' });
e.preventDefault();
return;
}
const stepX = (ax.step > 0 ? ax.step : (ax.max - ax.min) / 100) * (e.shiftKey ? 10 : 1);
const stepY = (ay.step > 0 ? ay.step : (ay.max - ay.min) / 100) * (e.shiftKey ? 10 : 1);
let handled = true;
const p = posRef.current;
switch (e.key) {
case 'ArrowLeft': emit({ ...p, x: p.x - stepX }); break;
case 'ArrowRight': emit({ ...p, x: p.x + stepX }); break;
case 'ArrowUp': emit({ ...p, y: p.y + stepY }); break;
case 'ArrowDown': emit({ ...p, y: p.y - stepY }); break;
default: handled = false;
}
if (handled) e.preventDefault();
}}
onDoubleClick={() => {
if (disabled) return;
emit({ x: ax.defaultValue, y: ay.defaultValue });
}}
onFocus={() => {
setIsFocusVisible(!pointerFocus.current);
pointerFocus.current = false;
}}
onBlur={() => setIsFocusVisible(false)}
style={{
width,
height,
touchAction: 'none',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: disabled ? 'not-allowed' : isDragging ? 'grabbing' : 'crosshair',
opacity: disabled ? 0.45 : undefined,
outline: 'none',
borderRadius: 10,
...(isFocusVisible && ring ? { boxShadow: `0 0 0 2px ${ring}` } : undefined),
}}
>
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }} aria-hidden="true">
<rect x={0.5} y={0.5} width={width - 1} height={height - 1} rx={9} fill={faceColor} stroke="rgba(255,255,255,0.1)" />
{gridLines}
<line x1={hx} y1={0} x2={hx} y2={height} stroke={accent} strokeWidth="1" opacity="0.35" />
<line x1={0} y1={hy} x2={width} y2={hy} stroke={accent} strokeWidth="1" opacity="0.35" />
<circle cx={hx} cy={hy} r={isDragging ? 9 : 7} fill={accent} opacity="0.25" />
<circle cx={hx} cy={hy} r={4.5} fill={accent} />
</svg>
{nameX && <input type="hidden" name={nameX} value={pos.x} readOnly />}
{nameY && <input type="hidden" name={nameY} value={pos.y} readOnly />}
</div>
{label && (
<span
style={{
fontFamily: theme.fontUI,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: theme.label,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)}
</div>
);
});

View file

@ -26,6 +26,10 @@ export interface SkinProps extends KnobCoreProps {
parseValue?: (text: string) => number | null;
/** Render a hidden form input carrying the current value. */
name?: string;
/** Show a floating value bubble above the knob while dragging. */
valueBubble?: boolean;
/** Formatter for the bubble text (defaults to the trimmed value). */
bubbleFormat?: (value: number) => string;
className?: string;
style?: React.CSSProperties;
}