Browse documentation

usePointerTarget

Pointer position in a component's own world units. Returns null when the pointer is away, so a machine can fall back to its idle behaviour.

J1 100°J2 -73°J3 -21°27, 54

Move the pointer over the panel. Leave it and the arm falls back to its rest pose.

Install

pnpm dlx shadcn@latest add https://robocn.dev/r/use-pointer-target.json

Usage

const pointer = usePointerTarget<SVGSVGElement>({
  within: "window",
  toWorld: (unit) => ({ x: unit.x * 132 - 66, y: 104 - unit.y * 118 }),
})

<svg ref={pointer.ref}>…</svg>

API

exports
PropTypeDefaultDescription
toWorld(unit: Vec2, rect: DOMRect) => Vec2Maps 0..1 inside the element's box into your units. Values run outside 0..1 once the pointer leaves.
within"element" | "window"Track only over the element, or anywhere on the page.
persistbooleanHold the last position after the pointer leaves.

Source

src/hooks/use-pointer-target.ts
"use client"

import * as React from "react"

import type { Vec2 } from "@/lib/robocn/kinematics"

export interface UsePointerTargetOptions {
  /**
   * Map a pointer position inside the element's box (0..1 on both axes) into
   * the component's own world units. Each machine supplies its own mapping.
   * Wrap it in `useCallback`, or the listener rebinds on every render.
   */
  toWorld: (unit: Vec2, rect: DOMRect) => Vec2
  /** Track the pointer anywhere on the page rather than only over the element. */
  within?: "element" | "window"
  /** Hold the last position after the pointer leaves instead of releasing. */
  persist?: boolean
  enabled?: boolean
}

export interface PointerTarget {
  /** Null while the pointer is away, so the machine can fall back to a behaviour. */
  target: Vec2 | null
  active: boolean
}

/**
 * Pointer position expressed in a component's world units. Returns null when
 * the pointer is not engaged, which lets a component fall back to its idle
 * behaviour instead of freezing mid-reach.
 */
export function usePointerTarget<T extends Element>(
  ref: React.RefObject<T | null>,
  { toWorld, within = "element", persist = false, enabled = true }: UsePointerTargetOptions,
): PointerTarget {
  const [target, setTarget] = React.useState<Vec2 | null>(null)
  const [active, setActive] = React.useState(false)

  React.useEffect(() => {
    const element = ref.current
    if (!enabled || !element) return
    const source: Element | Window = within === "window" ? window : element

    const onMove = (event: Event) => {
      const pointer = event as PointerEvent
      const rect = element.getBoundingClientRect()
      if (rect.width === 0 || rect.height === 0) return
      const unit = {
        x: (pointer.clientX - rect.left) / rect.width,
        y: (pointer.clientY - rect.top) / rect.height,
      }
      setTarget(toWorld(unit, rect))
      setActive(true)
    }
    const onLeave = () => {
      setActive(false)
      if (!persist) setTarget(null)
    }

    source.addEventListener("pointermove", onMove, { passive: true })
    element.addEventListener("pointerleave", onLeave)
    return () => {
      source.removeEventListener("pointermove", onMove)
      element.removeEventListener("pointerleave", onLeave)
      setTarget(null)
      setActive(false)
    }
  }, [ref, enabled, within, persist, toWorld])

  return { target, active }
}