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…

"use client"

import * as React from "react"

export interface UseMousePositionOptions {
  /** When given, coordinates and the 0..1 pair are also reported relative to this element; otherwise relative to the viewport. */
  ref?: React.RefObject<Element | null>
  /** Unbind the listener (no point tracking while the pointer-driven effect is off screen). Defaults to true. */
  enabled?: boolean
  /** Coalesce a frame's worth of pointermove events into one commit via requestAnimationFrame. Defaults to true. */
  throttleWithRaf?: boolean
}

export interface MousePosition {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-mouse-position.json

Prompt

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..1 is 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 pointermove far 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.
  • pointermove over mousemove — 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,0 would 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-none and aria-hidden, and should degrade to a static state under reduced motion; the underlying content must never depend on the pointer to be reachable.

On This Page