Skip to main content

Events

Five emitters. Nothing in them knows about shapes, canvases, CAD or furniture — the component emits direction and speed, you decide what that means.

HandlerFires
onStart(meta)A gesture begins.
onChange(delta, meta)Every frame while dragging.
onEnd(summary, meta)The gesture ends — one undo entry's worth.
onHover(hovering)Pointer enters or leaves the stick.
onAxisTap(axis, direction)An axis label was tapped, not dragged.

JoystickDelta — the per-frame slice

type JoystickDelta = {
x: number; // continuous, per-frame
y: number; // screen-up is +y, screen-right is +x
z: number;
operation: JoystickOperation;
magnitude: number; // 0–1 deflection, before the acceleration curve
angle?: number; // radians CCW from screen-right; rotate only, snapped
};

1.0 on an axis means one ring-radius of deflection, per frame. Multiply by whatever your app calls a unit — millimetres, pixels, degrees. The component never assumes.

magnitude is the raw deflection before acceleration, so it's the honest "how far is the thumb" number. The accelerated value is already baked into x/y/z.

JoystickEventMeta — input context

type JoystickEventMeta = {
pointerType: 'mouse' | 'touch' | 'pen';
shiftKey: boolean;
ctrlKey: boolean;
altKey: boolean;
timeStamp: number;
source?: 'pointer' | 'keyboard';
};

Keyboard gestures report pointerType: 'mouse' because PointerEvent has no keyboard value — check source === 'keyboard' if you need to tell them apart.

JoystickGestureSummary — one undo entry

type JoystickGestureSummary = {
total: { x: number; y: number; z: number };
operation: JoystickOperation;
durationMs: number;
dominantAxis: Axis | null; // null when the gesture moved nothing
cancelled: boolean; // true if Escape cancelled it; total is zeroed
};

This is the one to hang undo off. onChange fires per frame and would flood a history stack; onEnd fires once with the accumulated total.

<Joystick
onChange={(d) => applyLive(d)} // per frame, cheap
onEnd={(s) => {
if (!s.cancelled) undoStack.push(s); // once per gesture
}}
/>

cancelled matters. Pressing Escape mid-drag zeroes total and sets the flag, so you can revert instead of committing. Check it before writing to history.

onAxisTap

A motionless press under tapMaxMs on an axis label is a tap, not a drag — it emits one step in that direction instead of starting a gesture.

<Joystick onAxisTap={(axis, dir) => nudge(axis, dir * 1)} />

Spreading them

Every emitter is available as one type, which is convenient when passing handlers down:

import type { JoystickEvents } from '@jugaaadi/joystick';

function Panel(props: JoystickEvents) {
return <Joystick {...props} />;
}