API reference
Every export is available from the package root, as ESM or CommonJS:
import {
FloatingJoystick,
claimWidthFraction,
createPointerOwnership,
normalizeJoystick,
pointerOwnership,
useKeyboardVectorMap,
usePointerOwnership,
type ClaimArea,
type ClaimRect,
type FloatingJoystickProps,
type JoystickVector,
type KeyboardVector,
type KeyboardVectorMapOptions,
type OwnedPointers,
type PointerOwnership,
type PointerOwnershipTarget,
type PointerReleaseListener,
type RawOffset,
type UsePointerOwnershipOptions,
} from "input-joystick";FloatingJoystick
function FloatingJoystick(props: FloatingJoystickProps): JSX.Element;A touch-anywhere joystick. It renders a full-size, pointer-transparent wrapper
(data-floating-joystick="true") and, only while a pointer is held, a ring and knob
(data-testid="floating-joystick") positioned at the touch origin.
FloatingJoystickProps
| Prop | Type | Default | Meaning |
|---|---|---|---|
onChange | (vector: JoystickVector) => void | required | Called on every pointer move while held, and once with a zero vector when the pointer ends, is cancelled, or the component unmounts or is disabled. |
disabled | boolean | false | Releases any held pointer, reports a zero vector, and ignores new touches. |
label | string | "Movement joystick" | The title of the visible ring. |
radius | number | 58 | Travel radius in CSS pixels. The ring is 2 * radius wide. |
deadZone | number | 0.12 | Fraction of radius (0 to 1) inside which the magnitude is zero. |
accent | string | "#38bdf8" | A hex color for the ring glow and knob. Never read from a CSS custom property. |
allowMouse | boolean | false | Also accept pointerType: "mouse". Touch and pen are always accepted. |
hostSelector | string | '[data-testid="game-viewport"]' | Selector for the closest ancestor used as the hit-test host. Falls back to the parent element. |
claimArea | ClaimArea | the whole host | Which presses inside the host the stick may claim. See claimWidthFraction. |
ownership | PointerOwnership | the shared pointerOwnership | The registry the stick claims its pointer in. See PointerOwnership. |
owner | string | "joystick:" + a per-instance id | The name the stick claims under in ownership. Each default stick has its own, so two never both take one pointer. |
accent is concatenated with two-digit hex alpha suffixes, so pass a six-digit hex color such as
"#38bdf8".
Pointer rules
- A
pointerdownstarts the joystick only if no pointer is already claimed, the pointer is not a mouse (unlessallowMouse), the event target is inside the host and its rectangle, the target is not interactive,claimArea(when given) accepts it, and the pointer is not owned by anyone else inownership. The checks run in that order and the ownership claim is last, so a press the stick would not take never holds a claim. - Interactive targets are
button,a,input,textarea,select,summary,[role="button"], and anything with adata-joystick-ignoreattribute. - The stick claims its pointer in
ownershipasowner. While it holds it, every other owner'sclaimof that pointer is refused. - The claimed pointer's
pointermovecallsonChange; itspointeruporpointercancelends the gesture and releases the claim. Other pointers never affect it. - The gesture also ends, with a zero vector, when the stick's claim is released from the registry by
anyone:
clear(), or the window losing focus while the registry is attached. - Listeners are attached to
windowand removed on cleanup. ChangingallowMouse,deadZone,disabled,hostSelector,radius,ownershiporownerre-attaches them and releases any held pointer, claim included.claimAreais read through a ref likeonChange, so passing a new function on every render does not.
claimWidthFraction
type ClaimRect = Pick<DOMRectReadOnly, "left" | "top" | "right" | "bottom" | "width" | "height">;
type ClaimArea = (event: PointerEvent, rect: ClaimRect) => boolean;
function claimWidthFraction(side: "left" | "right", fraction: number): ClaimArea;A ClaimArea decides which presses the joystick may claim. It is called with the pointerdown and
the host's bounding rectangle, only for presses that are already inside the host, not on an
interactive element, and (for mouse) allowed. Return true to let the stick claim the press, false
to leave it for look, drag or taps. A rejected press is not prevented, claimed or reported.
claimWidthFraction(side, fraction) is the ready-made area: the fraction of the host's width
counted from the side edge.
// The left 40% of the screen steers; the rest is free for looking around.
<FloatingJoystick claimArea={claimWidthFraction("left", 0.4)} onChange={onMove} />"left"claimsclientX < rect.left + rect.width * fraction(the boundary is excluded)."right"claimsclientX >= rect.right - rect.width * fraction(the boundary is included), so("left", 0.5)and("right", 0.5)split a host with no column claimed twice and none missed.- The fraction is measured against the host's rectangle, not the page, so a host that does not start at the left edge of the page behaves the same.
- It throws a
RangeErrorunlessfractionis above 0 and at most 1.
Write your own area for anything else, such as a rectangle or a circle:
const lowerLeft: ClaimArea = (event, rect) =>
event.clientX < rect.left + rect.width / 2 && event.clientY > rect.top + rect.height / 2;PointerOwnership
interface PointerOwnership {
claim(pointerId: number, owner: string): boolean;
release(pointerId: number, owner?: string): boolean;
releaseAll(owner: string): number;
ownerOf(pointerId: number): string | undefined;
clear(): void;
onRelease(listener: (pointerId: number, owner: string) => void): () => void;
attach(target?: PointerOwnershipTarget): () => void;
}
function createPointerOwnership(): PointerOwnership;
const pointerOwnership: PointerOwnership; // the shared instanceA framework-free registry of which subsystem owns which pointer, so a finger that goes down on a
station, a widget or the joystick is not also a look-drag, a stick or a tap. It needs no React and no
DOM beyond the optional attach.
claim(pointerId, owner)returnstruewhen the pointer was free or alreadyowner's (calling again is safe) andfalsewhen another owner holds it. A refused claim changes nothing.release(pointerId, owner?)frees a pointer and returns whether it freed one. Withowner, only that owner's claim is released, so one subsystem never drops another's pointer; without it, the claim is released whoever holds it. Releasing a free pointer returnsfalse.releaseAll(owner)releases every pointerownerholds and returns the count.ownerOf(pointerId)is the holder, orundefinedwhen the pointer is free.clear()releases every claim, for every owner.onRelease(listener)is called aslistener(pointerId, owner)after each claim ends, whatever the cause. The pointer is already free when the listener runs, so it may claim it again. A listener added during a release hears the next one; one removed during a release is not called. It returns the unsubscribe function.attach(target = window)makes the registry release claims on its own: a claimed pointer is released whenpointeruporpointercancelreachestarget, and every claim is released onblur. It is reference-counted per target (the listeners go when the last attach is detached) and returns the detach function; calling that twice is harmless. The listeners run in the bubble phase, after the handlers that saw the event, so a handler that claims duringpointerup, or stopspointerupfrom reachingwindow, must release its own claim.
createPointerOwnership() makes an isolated registry. pointerOwnership is the shared one that
FloatingJoystick and usePointerOwnership use by default, so a joystick and your own subsystems
arbitrate with no wiring. Creating it has no side effects: nothing listens until something calls
attach.
import { pointerOwnership } from "input-joystick";
// A station takes the finger that touched it, so the stick and the look-drag leave it alone.
canvas.addEventListener("pointerdown", (event) => {
if (touchedStation(event)) pointerOwnership.claim(event.pointerId, "station");
});
// The look-drag asks before it turns the head.
function onLookMove(event: PointerEvent) {
const owner = pointerOwnership.ownerOf(event.pointerId);
if (owner !== undefined && owner !== "look") return;
// ...
}usePointerOwnership
function usePointerOwnership(
owner: string,
options?: { ownership?: PointerOwnership }
): OwnedPointers;
interface OwnedPointers {
claim(pointerId: number): boolean;
release(pointerId: number): boolean;
owns(pointerId: number): boolean;
heldByOther(pointerId: number): boolean;
ownerOf(pointerId: number): string | undefined;
}The registry seen from one owner, for React components. claim and release act as owner
(release never drops another owner's pointer); owns is whether you hold the pointer;
heldByOther is whether someone else does, which is the question a look or drag handler asks before
acting on a pointer; ownerOf is the holder.
While the component is mounted the hook attaches the registry (see attach above), so claims are
released when a pointer ends, is cancelled or the window loses focus. On unmount, or when owner or
ownership changes, every pointer owner still holds is released. The returned object is stable for
a given owner and registry, so it is safe in dependency arrays and handlers.
function LookSurface() {
const pointers = usePointerOwnership("look");
return (
<div
onPointerDown={(event) => {
if (!pointers.claim(event.pointerId)) return; // a station or the stick has it
// ...start the look-drag
}}
onPointerUp={(event) => pointers.release(event.pointerId)}
/>
);
}normalizeJoystick
function normalizeJoystick(raw: RawOffset, radius: number, deadZone: number): JoystickVector;Pure deadzone, clamp and normalize math. raw is the pointer offset in pixels from the joystick
origin.
- Offsets inside
deadZone * radiusreportmagnitude: 0and a zeroxandy. - Between the deadzone and the radius,
magnituderises linearly from 0 to 1. - Beyond
radiusthe vector clamps to unit length. angleisMath.atan2(y, x)of the offset direction in radians, in screen coordinates, so a positiveypoints down.- A
radiusof zero or less yields a zero magnitude.
JoystickVector and RawOffset
interface JoystickVector {
x: number; // -1..1
y: number; // -1..1
magnitude: number; // 0..1
angle: number; // radians
}
interface RawOffset {
x: number;
y: number;
}useKeyboardVectorMap
function useKeyboardVectorMap(options: KeyboardVectorMapOptions): void;
interface KeyboardVectorMapOptions {
onChange: (vector: KeyboardVector) => void;
keys?: Record<string, KeyboardVector>;
disabled?: boolean;
}
interface KeyboardVector {
x: number;
y: number;
}An opt-in keyboard co-map. keys maps KeyboardEvent.key values to vectors and defaults to
ArrowUp, ArrowDown, ArrowLeft, ArrowRight and lowercase w, a, s, d.
- A mapped
keydowncallsonChange(vector)and callspreventDefault(). Auto-repeat events are ignored. - A mapped
keyupcallsonChange({ x: 0, y: 0 }). - Events from
inputandtextareaelements are ignored. disabled: trueremoves the listeners without unmounting the hook.
The hook reports the most recent key's vector, not a sum of held keys, so it is a parity aid for
desktop testing rather than a full keyboard movement system. Pass a stable keys object (module
scope or memoized) to avoid re-attaching listeners on every render.