useRobotMotion
The clock every machine runs on and the handle you grab it by: a rate-limited scalar, pointer-capture dragging, and keyboard steps — all parked by a reduced-motion preference.
variant
view
Drawn by the machine this file solves for, so you can see the maths move.
Theming
Set a role and the same CSS goes in your own app — every robot under it follows.
Install
bunx --bun shadcn@latest add https://robocn.dev/r/use-robot-motion.jsonNotes
- Every uncontrolled robocn machine runs on this hook, which is why they all park together under a reduced-motion preference and all resume from where you left them.
- The goal is a function of the clock, so a behaviour is a pure function that can be sampled in a test at a fixed phase rather than driven by a timer.
Usage
const motion = useRobotScalar((clock) => Math.sin(clock * Math.PI * 2) * 90, {
rate: 210, // degrees per second on the way back
hold: dragging, // pin it while the pointer has it
speed: 0.25, // cycles per second
})
const dragging = useRobotDrag(svgRef, {
enabled: interactive,
onDrag: useCallback((unit) => setAngle(unit.x * 360 - 180), []),
})API
| Prop | Type | Default | Description |
|---|---|---|---|
| useRobotClock | (options?) => number | — | Seconds × speed since mount, offset by phase. Parks at phase when animation is off or reduced motion is preferred, and holds where it stands while paused. |
| useRobotScalar | (goal, options?) => { value, clock } | — | One frame loop that advances the clock and rate-limits a value toward goal(clock). hold pins the value while the clock keeps running underneath, so releasing a grabbed machine eases back into the cycle instead of snapping to it. |
| useRobotDrag | (ref, options) => boolean | — | Pointer capture on press, unit coordinates on every move, and the drag state back for the cursor. Pressing rather than hovering is what gives a touch device the same control a mouse has. |
| approach | (value, goal, step) => number | — | The rate limiter itself. Pure, so a component can use it directly. |
| arrowStep | (key, step, large?) => number | — | Arrow and page keys as a signed delta; zero for keys that are not ours. |
| useReducedMotion | () => boolean | — | Subscribes to the preference, so a change takes effect without a reload. |
Source
src/hooks/use-robot-motion.ts
"use client"
/**
* use-robot-motion — the clock every machine runs on, and the handle you grab
* it by.
*
* `useRobotArm` and `useEasedPoint` solve chains. These are for machines with
* no chain to solve: a stroke, an angle, a belt phase. The pair exists so all
* of them park under the same reduced-motion preference, stop rendering when
* they are still, and hand a person the same grab-and-release feel.
*/
import * as React from "react"
import { clamp, type Vec2 } from "@/lib/robocn/kinematics"
import { prefersReducedMotion } from "@/lib/robocn/style"
function subscribeReducedMotion(onChange: () => void) {
if (typeof window === "undefined" || !window.matchMedia) return () => {}
const media = window.matchMedia("(prefers-reduced-motion: reduce)")
media.addEventListener("change", onChange)
return () => media.removeEventListener("change", onChange)
}
/** True while the visitor has asked the OS for less animation. */
export function useReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
prefersReducedMotion,
() => false,
)
}
export interface RobotMotionOptions {
/** Cycles per second. Zero or non-finite parks the machine. */
speed?: number
/** Off renders the parked pose once. */
animate?: boolean
/** Freeze where it stands, without resetting. */
paused?: boolean
/** Seconds of offset, so a row of machines breaks step. */
phase?: number
}
/**
* Seconds since mount multiplied by `speed`, offset by `phase`. Parks at
* `phase` when animation is off or the visitor prefers reduced motion, and
* holds where it stands while paused.
*/
export function useRobotClock({
speed = 1,
animate = true,
paused = false,
phase = 0,
}: RobotMotionOptions = {}): number {
const reduced = useReducedMotion()
const enabled = animate && !reduced && Number.isFinite(speed) && speed !== 0
const [clock, setClock] = React.useState(phase)
// Disabled is deterministic: the same drawing every render, at `phase`.
React.useEffect(() => {
if (enabled) return
setClock(phase)
}, [enabled, phase])
React.useEffect(() => {
if (!enabled || paused) return
let last = performance.now()
let frame = requestAnimationFrame(function step(now) {
// A frame already queued when the loop starts carries a timestamp from
// before it, so the first delta can be negative: clamp both ends or the
// machine takes one step backwards at mount.
const delta = clamp((now - last) / 1000, 0, 0.05)
last = now
setClock((current) => current + delta * speed)
frame = requestAnimationFrame(step)
})
return () => cancelAnimationFrame(frame)
}, [enabled, paused, speed])
return clock
}
/** A fixed value, or where the value should be at `clock`. */
export type RobotGoal = number | ((clock: number) => number)
const sample = (goal: RobotGoal, clock: number) =>
typeof goal === "function" ? goal(clock) : goal
export interface RobotScalarOptions extends RobotMotionOptions {
/**
* Value units per second while easing toward the goal. `Infinity` snaps,
* which is what a controlled machine wants.
*/
rate?: number
/**
* Pin the value here and leave the clock running underneath: a controlled
* prop, or the pointer during a drag. Releasing it eases back into whatever
* the behavior has moved on to, rather than snapping to it.
*/
hold?: number | null
}
export interface RobotScalar {
value: number
/** Seconds × speed, for anything else the drawing derives from the cycle. */
clock: number
}
/**
* One animation frame loop that advances the clock and rate-limits a scalar
* toward its goal. The rate limit is the whole point: it is what makes a
* released machine return like a servo instead of teleporting.
*/
export function useRobotScalar(
goal: RobotGoal,
{
rate = 1,
hold = null,
speed = 1,
animate = true,
paused = false,
phase = 0,
}: RobotScalarOptions = {},
): RobotScalar {
const reduced = useReducedMotion()
const enabled = animate && !reduced && Number.isFinite(speed) && speed !== 0
// The loop reads the latest goal and hold without being torn down for each
// one; both are written after render, so it never sees a value the render
// did not produce.
const goalRef = React.useRef(goal)
const holdRef = React.useRef(hold)
React.useEffect(() => {
goalRef.current = goal
holdRef.current = hold
})
const [state, setState] = React.useState<RobotScalar>(() => ({
value: hold ?? sample(goal, phase),
clock: phase,
}))
const valueRef = React.useRef(state.value)
const clockRef = React.useRef(state.clock)
// Parked: sample the goal where it stands, so a changed goal, hold or phase
// still redraws even with no loop running to pick it up.
React.useEffect(() => {
if (enabled) return
const value = hold ?? sample(goal, phase)
valueRef.current = value
clockRef.current = phase
setState((current) =>
current.value === value && current.clock === phase
? current
: { value, clock: phase },
)
}, [enabled, goal, hold, phase])
// A grab is answered on the same frame it happens, loop running or not.
React.useEffect(() => {
if (hold === null || !enabled) return
valueRef.current = hold
setState((current) =>
current.value === hold ? current : { ...current, value: hold },
)
}, [hold, enabled])
React.useEffect(() => {
if (!enabled || paused) return
let clock = clockRef.current
let value = valueRef.current
let last = performance.now()
let frame = requestAnimationFrame(function step(now) {
// A frame already queued when the loop starts carries a timestamp from
// before it, so the first delta can be negative: clamp both ends or the
// machine takes one step backwards at mount.
const delta = clamp((now - last) / 1000, 0, 0.05)
last = now
clock += delta * speed
const held = holdRef.current
value =
held ?? approach(value, sample(goalRef.current, clock), rate * delta)
if (value !== valueRef.current || clock !== clockRef.current) {
valueRef.current = value
clockRef.current = clock
setState({ value, clock })
}
frame = requestAnimationFrame(step)
})
return () => cancelAnimationFrame(frame)
}, [enabled, paused, speed, rate])
return state
}
/**
* Move `value` toward `goal` by at most `step`. The rate limiter behind
* {@link useRobotScalar}, pure so a component can use it on its own.
*/
export function approach(value: number, goal: number, step: number) {
if (!Number.isFinite(goal)) return value
if (!(step > 0)) return value
const gap = goal - value
return Math.abs(gap) <= step ? goal : value + Math.sign(gap) * step
}
export interface RobotDragOptions {
enabled?: boolean
/**
* Where the pointer is inside the element's box, 0..1 on both axes, on
* press and on every move until release. Wrap it in `useCallback`, or the
* listeners rebind on every render.
*/
onDrag: (unit: Vec2, rect: DOMRect) => void
onDragEnd?: () => void
}
/**
* Press-and-drag control for a drawing. Pointer capture means the drag
* survives leaving the element, and pressing rather than hovering is what
* gives a touch device the same control a mouse has.
*/
export function useRobotDrag<T extends Element>(
ref: React.RefObject<T | null>,
{ enabled = true, onDrag, onDragEnd }: RobotDragOptions,
): boolean {
const [dragging, setDragging] = React.useState(false)
React.useEffect(() => {
const element = ref.current
if (!enabled || !element) return
let active: number | null = null
const report = (event: PointerEvent) => {
const rect = element.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
onDrag(
{
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height,
},
rect,
)
}
const onDown = (event: Event) => {
const pointer = event as PointerEvent
if (pointer.button > 0) return
active = pointer.pointerId
// Keeps the press from starting a text selection or a page scroll.
pointer.preventDefault()
// A pointer can be gone by the time the handler runs — a lifted finger, a
// synthesised event — and capturing a dead id throws. The drag still
// works off the element's own listeners without the capture.
try {
element.setPointerCapture?.(pointer.pointerId)
} catch {
// No capture: releases outside the element are handled by pointercancel.
}
setDragging(true)
report(pointer)
}
const onMove = (event: Event) => {
const pointer = event as PointerEvent
if (active === pointer.pointerId) report(pointer)
}
const onRelease = (event: Event) => {
const pointer = event as PointerEvent
if (active !== pointer.pointerId) return
if (element.hasPointerCapture?.(pointer.pointerId)) {
element.releasePointerCapture?.(pointer.pointerId)
}
active = null
setDragging(false)
onDragEnd?.()
}
element.addEventListener("pointerdown", onDown)
element.addEventListener("pointermove", onMove)
element.addEventListener("pointerup", onRelease)
element.addEventListener("pointercancel", onRelease)
return () => {
element.removeEventListener("pointerdown", onDown)
element.removeEventListener("pointermove", onMove)
element.removeEventListener("pointerup", onRelease)
element.removeEventListener("pointercancel", onRelease)
setDragging(false)
}
}, [ref, enabled, onDrag, onDragEnd])
return dragging
}
/**
* Arrow and page keys as a signed delta in value units; zero for a key that
* is not ours, so a component can fall through to its own handling.
*/
export function arrowStep(key: string, step: number, large = step * 3) {
switch (key) {
case "ArrowRight":
case "ArrowUp":
return step
case "ArrowLeft":
case "ArrowDown":
return -step
case "PageUp":
return large
case "PageDown":
return -large
default:
return 0
}
}