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.jsonAdds 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
"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
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;
}