Description
A pointer-driven DOM joystick for touch interfaces and React games. Choose radial movement or vertical-only input, then map its pixel offsets or discrete levels into your own controls.
The thumb is measured from its actual centre and returns to neutral on pointer release or cancellation. Customize its appearance with classes, inline thumb styles and children. Keep game rules, input locks, visibility resets and an accessible keyboard alternative in the host application. No Canvas or WebGPU renderer is required.
Agent skill
Add this resource
$threenix-add-component joystickImplementation
Source Code
Joystick
'use client'
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import type {
CSSProperties,
FC,
PropsWithChildren,
PointerEvent as ReactPointerEvent,
} from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_TRANSFORM = 'translate(-50%, 50%)'
const POINTER_MOVE_OPTIONS: AddEventListenerOptions = { passive: false }
type NativePointerEvent = globalThis.PointerEvent
// Joystick geometry & positioning defaults (all pixel values except x/y offsets)
// - maxRange: max travel distance from the pad center; movement is clamped to this radius.
// - level: number of discrete steps; outputs are scaled to integers in [-level, level].
// - radius: controls the outer pad's visual size; its measured bounds define the center.
// - joystickRadius: visual radius of the inner knob.
// - position: which side of the screen to anchor to (left/right).
// - x/y: CSS offsets that anchor the whole control relative to the chosen side and bottom.
const DEFAULT_OPTIONS = {
maxRange: 70,
level: 10,
radius: 60,
joystickRadius: 35,
position: 'right',
x: '40px',
y: '40px',
} as const
export type OnJoystickMove = {
x: number
y: number
leveledX: number
leveledY: number
angle: number
distance: number
}
type UseJoystickOptions = {
axis?: 'both' | 'vertical'
maxRange?: number
level?: number
radius?: number
position?: 'left' | 'right'
x?: string
y?: string
onMove?: (coordinates: OnJoystickMove) => void
}
export type JoystickProps = UseJoystickOptions & {
className?: string
controllerClassName?: string
joystickClassName?: string
/** Inline styles for the thumb, merged after its computed size. Leave `transform` to the hook. */
joystickStyle?: CSSProperties
joystickRadius?: number
}
const createInitialCoordinates = (): OnJoystickMove => ({
x: 0,
y: 0,
leveledX: 0,
leveledY: 0,
angle: 0,
distance: 0,
})
const roundTo = (value: number, precision = 4): number => {
const factor = 10 ** precision
return Math.round(value * factor) / factor
}
export const useJoystick = ({
axis = 'both',
maxRange = DEFAULT_OPTIONS.maxRange,
level = DEFAULT_OPTIONS.level,
position = DEFAULT_OPTIONS.position,
x = DEFAULT_OPTIONS.x,
y = DEFAULT_OPTIONS.y,
onMove,
}: UseJoystickOptions = {}) => {
const containerRef = useRef<HTMLDivElement>(null)
const controllerRef = useRef<HTMLDivElement>(null)
const joystickRef = useRef<HTMLDivElement>(null)
const coordinatesRef = useRef<OnJoystickMove>(createInitialCoordinates())
const pointerIdRef = useRef<number | null>(null)
const centerRef = useRef({ x: 0, y: 0 })
const centerReadyRef = useRef(false)
const rafRef = useRef<number | null>(null)
const hasPointerCaptureRef = useRef(false)
const onMoveRef = useRef(onMove)
useEffect(() => {
onMoveRef.current = onMove
}, [onMove])
const cancelRaf = useCallback(() => {
if (rafRef.current === null) return
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}, [])
const resetCoordinates = useCallback(() => {
const coordinates = coordinatesRef.current
coordinates.x = 0
coordinates.y = 0
coordinates.leveledX = 0
coordinates.leveledY = 0
coordinates.angle = 0
coordinates.distance = 0
const joystick = joystickRef.current
if (joystick) {
joystick.style.transform = BASE_TRANSFORM
}
onMoveRef.current?.(coordinates)
}, [])
const updateCenter = useCallback(() => {
const controller = controllerRef.current
if (!controller) return
const rect = controller.getBoundingClientRect()
centerRef.current.x = rect.left + rect.width / 2
centerRef.current.y = rect.top + rect.height / 2
centerReadyRef.current = true
}, [])
const updateCoordinates = useCallback(
(clientX: number, clientY: number) => {
if (!centerReadyRef.current) return
const center = centerRef.current
const dx = axis === 'vertical' ? 0 : clientX - center.x
const dy = clientY - center.y
const distance = Math.hypot(dx, dy)
const clampedDistance = Math.min(distance, maxRange)
const angle = Math.atan2(dy, dx)
const offsetX =
axis === 'vertical' ? 0 : Math.round(Math.cos(angle) * clampedDistance) || 0
const offsetY = -Math.round(Math.sin(angle) * clampedDistance) || 0
const effectiveRange = Math.max(maxRange, 1)
const coordinates = coordinatesRef.current
coordinates.x = offsetX
coordinates.y = offsetY
coordinates.leveledX = Math.round((offsetX / effectiveRange) * level)
coordinates.leveledY = Math.round((offsetY / effectiveRange) * level)
coordinates.distance = roundTo(distance)
coordinates.angle = roundTo(angle)
const joystick = joystickRef.current
if (joystick) {
joystick.style.transform = `${BASE_TRANSFORM} translate(${offsetX}px, ${-offsetY}px)`
}
onMoveRef.current?.(coordinates)
},
[axis, level, maxRange],
)
const handlePointerMove = useCallback(
(event: NativePointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return
event.preventDefault()
updateCoordinates(event.clientX, event.clientY)
},
[updateCoordinates],
)
const handlePointerUp = useCallback(
(event: NativePointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return
pointerIdRef.current = null
cancelRaf()
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
if (hasPointerCaptureRef.current && joystickRef.current) {
try {
joystickRef.current.releasePointerCapture(event.pointerId)
} catch {
// ignore release failures
}
}
hasPointerCaptureRef.current = false
resetCoordinates()
},
[cancelRaf, handlePointerMove, resetCoordinates],
)
const startTracking = useCallback(
(event: NativePointerEvent, allowPointerCapture: boolean) => {
if (pointerIdRef.current !== null) return
pointerIdRef.current = event.pointerId
updateCenter()
updateCoordinates(event.clientX, event.clientY)
window.addEventListener('pointermove', handlePointerMove, POINTER_MOVE_OPTIONS)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
cancelRaf()
rafRef.current = window.requestAnimationFrame(() => {
updateCenter()
updateCoordinates(event.clientX, event.clientY)
})
if (allowPointerCapture && joystickRef.current) {
try {
joystickRef.current.setPointerCapture(event.pointerId)
hasPointerCaptureRef.current = true
} catch {
hasPointerCaptureRef.current = false
}
} else {
hasPointerCaptureRef.current = false
}
},
[cancelRaf, handlePointerMove, handlePointerUp, updateCenter, updateCoordinates],
)
const handlePointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
event.preventDefault()
startTracking(event.nativeEvent as NativePointerEvent, true)
},
[startTracking],
)
useEffect(() => {
return () => {
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
cancelRaf()
}
}, [cancelRaf, handlePointerMove, handlePointerUp])
useEffect(() => {
window.addEventListener('resize', updateCenter)
return () => {
window.removeEventListener('resize', updateCenter)
}
}, [updateCenter])
useLayoutEffect(() => {
updateCenter()
resetCoordinates()
}, [updateCenter, resetCoordinates, x, y])
return {
containerRef,
controllerRef,
joystickRef,
horizontalOffset: x,
bottomOffset: y,
position,
coordinatesRef,
handlePointerDown,
}
}
const Joystick: FC<PropsWithChildren<JoystickProps>> = ({
children,
className,
controllerClassName,
joystickClassName,
joystickStyle,
axis,
maxRange,
level,
radius = DEFAULT_OPTIONS.radius,
joystickRadius = DEFAULT_OPTIONS.joystickRadius,
position = DEFAULT_OPTIONS.position,
x,
y,
onMove,
}) => {
const {
containerRef,
controllerRef,
joystickRef,
horizontalOffset,
bottomOffset,
position: resolvedPosition,
handlePointerDown,
} = useJoystick({
axis,
maxRange,
level,
radius,
position,
x,
y,
onMove,
})
const joystickClasses = twMerge(
'absolute left-1/2 bottom-1/2 rounded-full border border-black touch-none select-none shadow-[inset_0_-4px_8px_rgba(10,10,10,0.8)] bg-radial from-neutral-300 to-white',
joystickClassName,
)
const containerStyle =
resolvedPosition === 'left'
? { left: horizontalOffset, bottom: bottomOffset }
: { right: horizontalOffset, bottom: bottomOffset }
return (
<div
ref={containerRef}
style={containerStyle}
className={twMerge('fixed flex w-fit items-center justify-center select-none', className)}
role="presentation"
aria-hidden="true">
<div className="absolute size-5 rounded-full bg-black" />
<div
ref={controllerRef}
className={twMerge('relative', controllerClassName)}
style={{ width: radius * 1.5, height: radius * 1.5 }}>
<div
ref={joystickRef}
className={joystickClasses}
style={{
width: joystickRadius * 2,
height: joystickRadius * 2,
...joystickStyle,
}}
onPointerDown={handlePointerDown}>
{children}
</div>
</div>
</div>
)
}
export default Joystick