Skip to main content

Headless

useJoystick is all of the gesture behaviour with none of the pixels. Use it when the built-in chrome doesn't fit your design.

The circle and the thumb are plain divs styled by this site — every behaviour comes from the hook.

What you get back

const { bind, state } = useJoystick(options);

bind is a stable ref callback. Hand it straight to ref — it doesn't change identity between renders, so it won't churn your effects.

state is { dragging, x, y, magnitude }:

draggingwhether a gesture is in progress
x / ythumb offset in px, already clamped to the ring
magnitude0–1 deflection, before the acceleration curve

x/y are clamped for you, so a thumb rendered at that offset can never escape its circle no matter how far the pointer travels.

Options

Everything the component takes, plus two of its own:

OptionDefault
radiusfrom sizeRing radius in px. Overrides size.
tapMaxMs250Longest motionless press still a tap.
size140Only used to derive the radius.
operation'move'
axes['x','y']
zModefalseVertical drag drives z, horizontal ignored.
deadzone0.06
accelExponent2.2
maxSpeedMultiplier6
rotateSnapDeg15
disabledfalse
keyStep / keyStepMultiplier1 / 10

Plus every event from EventsonStart, onChange, onEnd, onHover, onAxisTap.

What the hook still handles for you

This is the part worth not rewriting:

  • Pointer capture — the drag survives the pointer leaving the element, including off-window
  • pointercancel — a system gesture releases the stick cleanly instead of leaving it stuck mid-drag
  • Tap vs drag — resolved by both time (tapMaxMs) and distance (4px of slop)
  • Keyboard — arrow keys step, Shift multiplies, Escape cancels the gesture and zeroes the total
  • The ring clamp and the acceleration curve

Two things you must supply

touch-action: none on the element, or dragging will scroll the page on touch devices instead of moving the stick.

tabIndex={0} if you want keyboard support — the hook attaches the handlers, but a plain div isn't focusable without it. Add an aria-label too.