Hooks
useMousePosition
A hook that tracks pointer position, optionally relative to an element, with clamped 0..1 normalized coordinates.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-mouse-position.jsonPrompt
Build a React + TypeScript "useMousePosition" hook (no dependencies beyond
React; uses Pointer Events only).
Contract
- `useMousePosition(options?): { x: number; y: number; elementX: number;
elementY: number; normalizedX: number; normalizedY: number; isInside:
boolean }` with `options?: { ref?: RefObject<Element | null>; enabled?:
boolean; throttleWithRaf?: boolean }`.
- Defaults: no `ref`, `enabled: true`, `throttleWithRaf: true`. Initial state
is all zeros with `isInside: false`.
- `x`/`y` are viewport coordinates (`clientX`/`clientY`). With a `ref`,
`elementX`/`elementY` are relative to that element's top-left and
`normalizedX`/`normalizedY` are those divided by the element's width/height;
without a `ref` they fall back to viewport coordinates normalized against
`innerWidth`/`innerHeight`. Export the result interface.
Behavior
- Listen to `pointermove` on `window` (not `mousemove`): one event type
covers mouse, touch and stylus, so no parallel touch branch is needed.
- With `throttleWithRaf` on (the default), store only the latest
`{ clientX, clientY }` in a closure variable and schedule a single
`requestAnimationFrame` to commit it; if a frame is already scheduled, drop
the event. `pointermove` fires hundreds of times per second — committing
each one is the difference between a smooth effect and a stuttering page.
With it off, commit synchronously per event.
- Recompute `getBoundingClientRect()` inside each commit, not once at mount:
the element can scroll or be reflowed. Because commits are already capped
at one per frame, so are the layout reads.
- `normalizedX`/`normalizedY` are always clamped to 0..1 (and fall back to 0
when the element measures 0 in that axis, so nothing ever produces NaN).
Consumers can feed them straight into a gradient position or a rotation
without writing their own bounds checks.
- `isInside` is a plain rectangle test against the element's rect; without a
`ref` it means "the pointer is over the document". A `pointerleave` on
`document` flips it to false while leaving the last coordinates frozen —
effects should fade out rather than snap to a corner.
- `enabled: false` unbinds the listeners entirely (not just an early return
in the handler) so an off-screen effect costs nothing. Cleanup removes both
listeners and cancels any pending rAF on unmount and on any option change.
- SSR-safe: listeners are attached in an effect, the first render returns the
zeroed state with `isInside: false`.
Rendering & styling
- The hook renders nothing. Anything driven by it should be an
`aria-hidden`, `pointer-events-none` decorative layer so it can never
intercept the interactions it is decorating.
- Colours come from tokens even in inline styles: build highlights with
`color-mix(in oklab, var(--primary) 22%, transparent)` inside a
`radial-gradient`, never a hex literal, so the effect follows light and
dark themes.
- The pointer-driven transform itself is direct manipulation and should not
be transitioned (it would lag behind the cursor); transition only the
fade in/out on `isInside`, and add `motion-reduce:transition-none`. Under
`prefers-reduced-motion: reduce`, drop the movement entirely (a static
highlight or nothing at all) — the surface must remain fully usable
without it.
Customization levers
- `ref` — omit it for a global cursor follower, pass it for anything scoped
to a card; the same hook powers both.
- `throttleWithRaf: false` — only when a consumer already batches work
itself (driving a `motion` value or writing directly to a CSS custom
property), where the extra frame of latency is not wanted.
- `enabled` — gate on hover, on an intersection observer, or on
`prefers-reduced-motion` to skip the effect entirely for users who asked
for less motion.
- What to drive with the normalized pair: gradient position (spotlight),
`rotateX/rotateY` (tilt), translate on a background layer (parallax), or
the distance from centre (`normalized - 0.5`) for magnetic pull.
- Higher-fidelity input — `event.getCoalescedEvents()` gives every sample the
browser merged into one frame, worth adding for drawing surfaces; page
coordinates (`pageX/pageY`) instead of client coordinates are a two-line
change when the effect must survive scrolling.Concepts
- Normalized coordinates as the real product — raw pixels are specific to one element's size;
0..1is what gradients, rotations and parallax offsets actually want, and clamping it means a consumer never has to defend against negative or >1 values when the pointer leaves. - Per-frame commits — the browser can fire
pointermovefar faster than it paints; keeping only the newest sample and committing once per animation frame turns an unbounded stream of renders into exactly the number the display can show. pointermoveovermousemove— Pointer Events unify mouse, touch and pen, so a stylus hover or a touch drag drives the same effect without a second code path.- Inside/outside as a fade signal, not a reset — coordinates stay at their last value when the pointer leaves so effects can fade out from where they were; snapping to
0,0would yank the highlight to the corner on every exit. - Rect read per commit — measuring inside the commit (rather than caching at mount) is what keeps the effect correct while the page scrolls or the card is reflowed, and it stays cheap because commits are already throttled.
- Decorative by construction — everything this hook drives should be
pointer-events-noneandaria-hidden, and should degrade to a static state under reduced motion; the underlying content must never depend on the pointer to be reachable.