hover-ui

useCursor

The cursor engine every hover-ui cursor runs on: turns its parent element into a cursor area and calls your frame function while a mouse is inside.

npx shadcn@latest add https://hoverui.fun/r/use-cursor.json

Adds use-cursor.ts to your hooks folder. Every cursor installs it for you; add it on its own to build your own cursor.

Used by

Your own cursor

dot-cursor.tsx
"use client";

import { useRef } from "react";
import { cursorRoot, useCursor } from "@/hooks/use-cursor";

export function DotCursor({ className = "" }: { className?: string }) {
  const pos = useRef({ x: 0, y: 0 });
  const ref = useCursor((p, el) => {
    pos.current.x += (p.x - pos.current.x) * p.ease(16);
    pos.current.y += (p.y - pos.current.y) * p.ease(16);
    (el.firstElementChild as HTMLElement).style.transform = `translate(${pos.current.x}px, ${pos.current.y}px)`;
  });

  return (
    <div ref={ref} aria-hidden className={`${cursorRoot} ${className}`}>
      <div className="absolute size-3 -translate-1/2 rounded-full bg-current" />
    </div>
  );
}

Place it like any hover-ui cursor: inside the element it should own, or in <body> for the whole page.

API

useCursor(frame)
Returns a ref for your cursor's root element. That element's parent becomes the cursor area: the native cursor hides there, and frame(pointer, element) runs every animation frame while a mouse is inside. Several cursors in one element share it: one pointer, one loop, called in the order they mounted.
pointer.x, pointer.y
The pointer position in viewport pixels.
pointer.down
True while the main mouse button is held.
pointer.target
The element under the pointer.
pointer.hover
The clickable element under the pointer (anything matching INTERACTIVE) inside the cursor's area, or null: if (pointer.hover) grow(). A link wrapping the whole area doesn't count.
pointer.ease(speed)
A frame-rate independent smoothing factor: pos += (pointer.x - pos) * pointer.ease(12). Returns 1 on entry, on a cursor's first frame and under reduced motion, so cursors snap instead of trailing.
pointer.dt
Seconds since the last frame, for springs, particles and anything time-based. 0 on the frame the pointer enters, and on a cursor's first frame: set things up when it is 0.
pointer.reduced
True when the visitor prefers reduced motion: skip anything that moves on its own, like bobbing, spinning or trails.
cursorRoot
Tailwind classes for the root: fixed, pointer-events-none, hidden until the pointer is inside the area.
INTERACTIVE
The selector cursors treat as hover targets.

Source

use-cursor.ts
import { useEffect, useEffectEvent, useRef } from "react";

export type Pointer = {
  x: number;
  y: number;
  /** Mouse button held. */
  down: boolean;
  /** Element under the pointer. */
  target: Element | null;
  /** The clickable element under the pointer (anything matching `INTERACTIVE`) inside this cursor's area, for hover effects. */
  hover: Element | null;
  /** Per-frame smoothing: `pos += (p.x - pos) * p.ease(speed)`. Frame-rate independent; 1 (snap) on a cursor's first frame and under reduced motion. */
  ease: (speed: number) => number;
  /** Seconds since the last frame (0 on the frame the pointer enters, and on a cursor's first frame), for time-based motion. */
  dt: number;
  /** The visitor prefers reduced motion: skip anything that moves on its own. */
  reduced: boolean;
};

/** Root classes every cursor shares: fixed overlay, hidden until the pointer is inside its area. */
export const cursorRoot =
  "pointer-events-none fixed left-0 top-0 z-9999 opacity-0 transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] data-active:opacity-100";

/** Things a cursor should react to on hover. */
export const INTERACTIVE = "a,button,input,select,textarea,label,[role=button],[data-cursor-label]";

type Cursor = { el: HTMLElement; fresh: boolean; frame(p: Pointer, el: HTMLElement): void };
type Area = { cursors: Cursor[]; active: () => boolean; close: () => void };

/** Every cursor area on the page and the cursors in it. Cursors in one element share it: one pointer, one loop. */
const areas = new Map<HTMLElement, Area>();

/** Makes `area` a cursor area: hides the native cursor there and, while a mouse is inside, runs one loop calling each of `cursors` in turn. */
function open(area: HTMLElement, cursors: Cursor[]): Area {
  if (!document.getElementById("hover-ui-cursor")) {
    const style = document.createElement("style");
    style.id = "hover-ui-cursor";
    style.textContent = "[data-cursor-area],[data-cursor-area] *{cursor:none!important}";
    document.head.append(style);
  }
  area.setAttribute("data-cursor-area", "");

  const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
  let raf = 0;
  let last = 0;
  let snap = true;
  const p: Pointer = {
    x: 0,
    y: 0,
    down: false,
    target: null,
    hover: null,
    ease: (speed) => (!p.dt || reduced ? 1 : 1 - Math.exp(-speed * p.dt)),
    dt: 0,
    reduced,
  };

  const tick = (t: number) => {
    const dt = Math.min((t - last) / 1000, 0.1);
    last = t;
    // In the order they mounted. A cursor that joined since the last frame gets a first frame of its own, to set itself up.
    for (const cursor of cursors) {
      p.dt = snap || cursor.fresh ? 0 : dt;
      cursor.fresh = false;
      cursor.frame(p, cursor.el);
    }
    snap = false;
    raf = requestAnimationFrame(tick);
  };
  const setActive = (on: boolean) => {
    if (on === !!raf) return;
    for (const cursor of cursors) cursor.el.toggleAttribute("data-active", on);
    cancelAnimationFrame(raf);
    raf = on ? requestAnimationFrame(tick) : 0;
    snap = true;
  };

  const ac = new AbortController();
  const opts = { signal: ac.signal };
  const track = (e: PointerEvent) => {
    // A touch on a laptop's screen is not the mouse: leave the cursor where the mouse left it.
    if (e.pointerType === "touch") return;
    p.x = e.clientX;
    p.y = e.clientY;
    p.target = e.target as Element;
    // A link wrapping the whole area (a card, say) is not something to hover inside it.
    const hit = p.target.closest(INTERACTIVE);
    p.hover = hit && hit !== area && area.contains(hit) ? hit : null;
    setActive(p.target.closest("[data-cursor-area]") === area);
  };
  area.addEventListener("pointermove", track, opts);
  // Scrolling under a still pointer fires no pointermove, only boundary events: without this the
  // old area deactivates on pointerleave and nothing takes over, leaving no cursor at all.
  area.addEventListener("pointerover", track, opts);
  area.addEventListener("pointerleave", () => setActive(false), opts);
  // The main button only: a right-click opens a menu that swallows its pointerup.
  area.addEventListener("pointerdown", (e) => (p.down = e.button === 0), opts);
  window.addEventListener("pointerup", () => (p.down = false), opts);
  window.addEventListener("blur", () => (p.down = false), opts);

  return {
    cursors,
    active: () => !!raf,
    close: () => {
      ac.abort();
      setActive(false);
      area.removeAttribute("data-cursor-area");
    },
  };
}

/**
 * Turns the parent of the returned ref's element into a cursor area: hides the native cursor
 * there and calls `frame` every animation frame while a mouse is inside.
 * Put a cursor in <body> for a global one; nested areas take over from outer ones. Several cursors
 * in one element share it, drawn in the order they mounted, and each can come and go on its own.
 * Note: position:fixed breaks under a transformed/filtered ancestor; portal to body if that bites.
 */
export function useCursor<T extends HTMLElement = HTMLDivElement>(frame: (p: Pointer, el: T) => void) {
  const ref = useRef<T>(null);
  const onFrame = useEffectEvent(frame);

  useEffect(() => {
    const el = ref.current;
    const area = el?.parentElement;
    if (!el || !area || !matchMedia("(hover: hover) and (pointer: fine)").matches) return;

    // The first cursor in an element opens the area; the last to leave closes it.
    let shared = areas.get(area);
    if (!shared) areas.set(area, (shared = open(area, [])));
    const cursor: Cursor = { el, fresh: true, frame: (p) => onFrame(p, el) };
    shared.cursors.push(cursor);
    el.toggleAttribute("data-active", shared.active());

    return () => {
      shared.cursors.splice(shared.cursors.indexOf(cursor), 1);
      el.removeAttribute("data-active");
      if (shared.cursors.length) return;
      shared.close();
      areas.delete(area);
    };
  }, []);

  return ref;
}