Hooks

useHover

A callback-ref hover hook for driving non-visual side effects, not styling.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseHoverResult<T extends HTMLElement> {
  /** Callback ref — attach it to the element you want to track hover on. */
  ref: (node: T | null) => void
  hovered: boolean
}

/**
 * Subscribes to one element's hover state through a **callback ref**, not
 * `useRef` + `useEffect` — same reason as `useIntersectionObserver`: when a
 * conditional render swaps the element out (new key, different branch), React

Installation

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

Prompt

Build a React + TypeScript "useHover" hook (no dependencies beyond React).

Contract
- `useHover<T extends HTMLElement = HTMLElement>(): { ref: (node: T | null) =>
  void; hovered: boolean }`.
- `ref` is a callback ref — attach it to the single element whose hover state
  you want to track. No options object; the hook has no configurable
  parameters.

Behavior
- The `ref` callback binds `pointerenter`/`pointerleave` listeners on the node
  it receives, using Pointer Events (not `mouseenter`/`mouseleave`) so pen
  input is covered alongside mouse. Touch input does not produce a real hover:
  on touch-only devices `pointerenter` either never fires or fires briefly on
  tap — that's platform semantics, not something the hook works around.
- `hovered` is only ever set inside the `pointerenter`/`pointerleave` event
  callbacks (asynchronous, browser-triggered) — never synchronously during
  render or inside a bare effect body.
- When the callback ref is invoked with a different node (element swapped via
  a key/branch change) or with `null` (unmount), it removes the listeners from
  the *previous* node (kept in a ref, not assumed to still be the argument)
  and resets `hovered` to `false`, so a freshly mounted replacement node never
  inherits a stale "hovered: true" from the node it replaced.
- Multiple components each calling `useHover()` own fully independent state —
  no shared/module-level hover flag.

Rendering & styling
- The hook renders nothing and holds no opinion on styling. Consumers decide
  what `hovered` drives — semantic tokens (`bg-primary`, `text-muted-foreground`)
  for any visual feedback layered on top, `motion-reduce:` variants for any
  transition tied to it.

Customization levers
- Focus-equivalent variant: if the same hover-driven logic must also fire on
  keyboard focus (accessibility parity), also bind `focus`/`blur` on the node
  inside the same callback ref and OR them into one `hovered` state — this
  hook intentionally ships pointer-only by default so it never has to guess
  whether a given hover use case wants focus parity.
- Delay threshold: wrap the consumer's side effect (not the hook itself) in a
  `setTimeout` keyed off `hovered`, cancelled on cleanup if `hovered` flips
  back to `false` before it fires — this keeps the hook itself a plain boolean
  and lets each consumer pick its own delay (see the prefetch demo).
- This hook deliberately does NOT debounce or delay `hovered` itself, and does
  NOT track pointer coordinates (see `useMousePosition` for that) — it is
  purely a hover-boundary boolean.

Concepts

  • Callback-ref rebinding — the same pattern as useIntersectionObserver: because React calls a callback ref on every mount/replace/unmount, the hook always knows exactly which node it's bound to, so swapping the tracked element (conditional render, key change) re-binds for free without a separate effect watching a ref object.
  • Pointer Events over mouse eventspointerenter/pointerleave cover mouse and pen; touch does not produce a genuine hover and is intentionally not special-cased.
  • Hover as a trigger, not a stylehovered is meant to gate a side effect (prefetch, delayed popover, preview playback). Purely visual hover feedback belongs in CSS :hover/group-hover, never in this hook.
  • Stale-state guard on replacement — detaching the previous node's listeners and resetting hovered to false on every ref call (including null) prevents a node swap from leaving a phantom "hovered" state behind.
  • Not a focus equivalent — keyboard users never trigger pointerenter; a hover-driven interaction that must also be keyboard-reachable needs an explicit focus/blur merge on top of this hook.

On This Page