Snapping Cursor
A cursor dot that latches onto interactive elements and takes their exact shape and corner radius, leans toward the pointer while locked, follows keyboard focus and lights up on touch. No dependencies.
DOM + CSS0 deps1 file · 13 KB TSXreduced-motion aware
Props
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export type CursorSnapTargetProps = {
/** Anything. Descendants carrying data-cursor-snap become snap targets. */
children: ReactNode;
/** Diameter of the idle dot in px. */
idleSize?: number;
/** Gap in px between a target's edge and the ring that wraps it. */
padding?: number;
/** Keep the dot visible in open space. Off, it only appears when snapped. */
showIdleDot?: boolean;
/** Magnetic pull: how far (0 to 0.5) the ring leans toward the pointer while locked. 0 pins it. */
magnet?: number;
/** CSS selector of a descendant to park the ring around while no pointer is in the wrapper (a resting state). Default: none. */
restTarget?: string;
/** Ring colour: #hex, rgb(), hsl() or oklch(). */
color?: string;
/** Goes on the wrapper element. */
className?: string;
};
const SELECTOR = "[data-cursor-snap]:not(:disabled):not([aria-disabled='true'])";
const COLOR_OK = /^(#[0-9a-f]{3,8}|(rgb|hsl|oklch)a?\([0-9a-z\s.,%/+-]{3,48}\))$/i;
const DEFAULT_COLOR = "#3b82f6";
const MAX_LEAN = 12;
type Spring = { v: number; t: number; vel: number; eps: number };
const spring = (v: number, eps: number): Spring => ({ v, t: v, vel: 0, eps });
const settled = (s: Spring) => Math.abs(s.v - s.t) < s.eps && Math.abs(s.vel) < s.eps * 2;
const advance = (s: Spring, k: number, c: number, m: number, dt: number) => {
const a = (-k * (s.v - s.t) - c * s.vel) / m;
s.vel += a * dt;
s.v += s.vel * dt;
};
/** First corner radius of an element, in px, clamped to a pill. */
function readRadius(el: HTMLElement, w: number, h: number) {
const first = (getComputedStyle(el).borderTopLeftRadius || "0").split(" ")[0] ?? "0";
const n = parseFloat(first);
const px = Number.isFinite(n) ? (first.endsWith("%") ? (n / 100) * Math.min(w, h) : n) : 0;
return Math.max(0, Math.min(px, Math.min(w, h) / 2));
}
export function CursorSnapTarget({
children,
idleSize = 10,
padding = 6,
showIdleDot = true,
magnet = 0.16,
restTarget,
color = DEFAULT_COLOR,
className,
}: CursorSnapTargetProps) {
const boxRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const glowRef = useRef<HTMLSpanElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
const spotRef = useRef<HTMLSpanElement>(null);
const cfg = useRef({ idleSize, padding, showIdleDot, magnet, restTarget });
const wakeRef = useRef<(() => void) | null>(null);
const tint = COLOR_OK.test(color.trim()) ? color.trim() : DEFAULT_COLOR;
useEffect(() => {
cfg.current = { idleSize, padding, showIdleDot, magnet, restTarget };
wakeRef.current?.();
}, [idleSize, padding, showIdleDot, magnet, restTarget]);
useEffect(() => {
const box = boxRef.current;
const ring = ringRef.current;
const glow = glowRef.current;
const fill = fillRef.current;
const spot = spotRef.current;
if (!box || !ring || !glow || !fill || !spot) return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
let reduce = mq.matches;
const onMq = () => {
reduce = mq.matches;
};
mq.addEventListener("change", onMq);
const sp = {
x: spring(0, 0.04),
y: spring(0, 0.04),
w: spring(0, 0.04),
h: spring(0, 0.04),
r: spring(0, 0.04),
a: spring(0, 0.002),
f: spring(0.9, 0.002),
g: spring(0, 0.002),
s: spring(1, 0.0006),
l: spring(0, 0.002),
};
const st = {
px: 0,
py: 0,
inside: false,
touch: false,
down: false,
hover: null as HTMLElement | null,
focus: null as HTMLElement | null,
radiusKey: "",
radiusEl: null as HTMLElement | null,
radius: 0,
};
let raf = 0;
let last = 0;
let lastKey = "";
let touchTimer = 0;
const aim = () => {
const c = cfg.current;
const rect = box.getBoundingClientRect();
const k = rect.width > 0 ? box.offsetWidth / rect.width : 1;
let el = st.hover ?? st.focus;
if (!el && !st.inside && c.restTarget) {
try {
el = box.querySelector<HTMLElement>(c.restTarget);
} catch {
el = null;
}
}
if (el && !el.isConnected) {
st.hover = null;
st.focus = null;
el = null;
}
let x: number;
let y: number;
let w: number;
let h: number;
let rad: number;
let alpha: number;
let fillT: number;
let glowT: number;
let litT: number;
const mouse = !st.touch;
if (el) {
const r = el.getBoundingClientRect();
const bw = r.width * k;
const bh = r.height * k;
w = bw + c.padding * 2;
h = bh + c.padding * 2;
x = (r.left - rect.left) * k - c.padding;
y = (r.top - rect.top) * k - c.padding;
const key = Math.round(bw) + "x" + Math.round(bh);
if (el !== st.radiusEl || key !== st.radiusKey) {
st.radiusEl = el;
st.radiusKey = key;
st.radius = readRadius(el, bw, bh);
}
rad = Math.min(st.radius + c.padding, w / 2, h / 2);
if (st.hover === el && st.inside && c.magnet > 0 && !reduce) {
let dx = (st.px - (x + w / 2)) * c.magnet;
let dy = (st.py - (y + h / 2)) * c.magnet;
const len = Math.hypot(dx, dy);
if (len > MAX_LEAN) {
dx *= MAX_LEAN / len;
dy *= MAX_LEAN / len;
}
x += dx;
y += dy;
}
alpha = 1;
fillT = st.down ? 0.26 : 0.13;
glowT = 0.3;
litT = st.hover === el && st.inside ? 1 : 0.5;
} else {
w = c.idleSize;
h = c.idleSize;
x = st.px - w / 2;
y = st.py - h / 2;
rad = w / 2;
alpha = st.inside && mouse && c.showIdleDot ? 1 : 0;
fillT = 0.9;
glowT = 0.22;
litT = 0;
}
sp.x.t = x;
sp.y.t = y;
sp.w.t = w;
sp.h.t = h;
sp.r.t = rad;
sp.a.t = alpha;
sp.f.t = fillT;
sp.g.t = glowT;
sp.l.t = litT;
sp.s.t = st.down ? (el ? 0.965 : 0.72) : 1;
// Entering from nothing: appear in place rather than flying in.
if (alpha > 0 && sp.a.v < 0.02) {
sp.x.v = x;
sp.y.v = y;
sp.w.v = w;
sp.h.v = h;
sp.r.v = rad;
sp.x.vel = sp.y.vel = sp.w.vel = sp.h.vel = sp.r.vel = 0;
}
return x + "," + y + "," + w + "," + h + "," + st.px + "," + st.py + "," + alpha + "," + st.down + "," + (el ? 1 : 0);
};
const paint = () => {
ring.style.width = sp.w.v + "px";
ring.style.height = sp.h.v + "px";
ring.style.borderRadius = Math.max(0, sp.r.v) + "px";
ring.style.transform = "translate3d(" + sp.x.v + "px," + sp.y.v + "px,0) scale(" + sp.s.v + ")";
ring.style.opacity = String(Math.max(0, Math.min(1, sp.a.v)));
fill.style.opacity = String(Math.max(0, Math.min(1, sp.f.v)));
glow.style.opacity = String(Math.max(0, Math.min(1, sp.g.v)));
spot.style.opacity = String(Math.max(0, Math.min(1, sp.l.v)) * 0.34);
const mask = "radial-gradient(circle 90px at " + (st.px - sp.x.v) + "px " + (st.py - sp.y.v) + "px, #000, transparent)";
spot.style.maskImage = mask;
spot.style.webkitMaskImage = mask;
};
const frame = (now: number) => {
raf = 0;
const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
last = now;
const key = aim();
const all = Object.values(sp);
if (reduce) {
for (const s of all) {
s.v = s.t;
s.vel = 0;
}
} else {
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < n; i++) {
advance(sp.x, 480, 36, 0.6, h);
advance(sp.y, 480, 36, 0.6, h);
advance(sp.w, 480, 36, 0.6, h);
advance(sp.h, 480, 36, 0.6, h);
advance(sp.r, 480, 36, 0.6, h);
advance(sp.s, 700, 34, 0.5, h);
advance(sp.a, 260, 30, 1, h);
advance(sp.f, 260, 30, 1, h);
advance(sp.g, 160, 24, 1, h);
advance(sp.l, 160, 24, 1, h);
}
}
paint();
if (key === lastKey && all.every(settled)) {
last = 0;
return;
}
lastKey = key;
raf = requestAnimationFrame(frame);
};
const wake = () => {
if (!raf) {
last = 0;
raf = requestAnimationFrame(frame);
}
};
wakeRef.current = wake;
const local = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
const k = r.width > 0 ? box.offsetWidth / r.width : 1;
st.px = (e.clientX - r.left) * k;
st.py = (e.clientY - r.top) * k;
};
const pick = (t: EventTarget | null) => {
if (!(t instanceof Element)) return null;
const el = t.closest<HTMLElement>(SELECTOR);
return el && box.contains(el) ? el : null;
};
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
st.touch = false;
st.inside = true;
local(e);
st.hover = pick(e.target);
wake();
};
const onDown = (e: PointerEvent) => {
window.clearTimeout(touchTimer);
st.touch = e.pointerType === "touch";
st.inside = true;
st.down = true;
local(e);
st.hover = pick(e.target);
wake();
};
const onUp = (e: PointerEvent) => {
if (!st.down) return;
st.down = false;
if (e.pointerType === "touch") {
// Let the highlight linger a beat so a quick tap still reads.
touchTimer = window.setTimeout(() => {
st.hover = null;
st.inside = false;
wake();
}, 420);
}
wake();
};
const onLeave = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
st.inside = false;
st.hover = null;
wake();
};
const onFocusIn = (e: FocusEvent) => {
const el = pick(e.target);
if (!el) return;
let visible = true;
try {
visible = el.matches(":focus-visible");
} catch {
visible = true;
}
if (visible) {
st.focus = el;
wake();
}
};
const onFocusOut = (e: FocusEvent) => {
if (e.target === st.focus) {
st.focus = null;
wake();
}
};
const onShift = () => {
if (st.hover || st.focus) wake();
};
box.addEventListener("pointermove", onMove);
box.addEventListener("pointerdown", onDown);
box.addEventListener("pointerleave", onLeave);
box.addEventListener("focusin", onFocusIn);
box.addEventListener("focusout", onFocusOut);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
window.addEventListener("scroll", onShift, { capture: true, passive: true });
window.addEventListener("resize", onShift);
wake();
return () => {
wakeRef.current = null;
cancelAnimationFrame(raf);
window.clearTimeout(touchTimer);
mq.removeEventListener("change", onMq);
box.removeEventListener("pointermove", onMove);
box.removeEventListener("pointerdown", onDown);
box.removeEventListener("pointerleave", onLeave);
box.removeEventListener("focusin", onFocusIn);
box.removeEventListener("focusout", onFocusOut);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
window.removeEventListener("scroll", onShift, true);
window.removeEventListener("resize", onShift);
};
}, []);
return (
<div ref={boxRef} className={"relative cursor-none" + (className ? " " + className : "")}>
{children}
<div
ref={ringRef}
aria-hidden
style={{
position: "absolute",
left: 0,
top: 0,
width: 0,
height: 0,
opacity: 0,
pointerEvents: "none",
boxSizing: "border-box",
willChange: "transform, width, height",
zIndex: 50,
}}
>
<span
ref={glowRef}
style={{
position: "absolute",
inset: 0,
borderRadius: "inherit",
background: tint,
filter: "blur(12px)",
opacity: 0,
}}
/>
<span
style={{
position: "absolute",
inset: 0,
overflow: "hidden",
borderRadius: "inherit",
border: "1px solid " + tint,
boxSizing: "border-box",
}}
>
<span ref={fillRef} style={{ position: "absolute", inset: 0, background: tint, opacity: 0.9 }} />
<span ref={spotRef} style={{ position: "absolute", inset: 0, background: tint, opacity: 0 }} />
</span>
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
In open space the cursor is a small solid dot. Cross any element marked data-cursor-snap and it commits: the dot grows into that element's measured box plus your padding, and its corner radius morphs to match the target's own, so a pill gets a pill, a card gets a soft rectangle and a round button gets a circle. Width, height, position and radius all ride the same critically damped spring (stiffness 480, damping 36, mass 0.6), so the circle-to-shape change is one continuous motion. Free tracking is suspended while locked, which is what makes it feel like a detent, but the ring leans a few pixels toward the pointer (the magnet prop, capped at 12px) and a soft light follows the pointer across its surface, so the lock stays alive. Pressing squeezes the ring and deepens its fill. The target is re-measured every frame while locked, so scrolling, resizing or an animating button never leaves the ring behind. Keyboard focus-visible snaps the ring to the focused element with no pointer involved, and on touch a press lights the ring on the target and lets it fade a beat after release. The whole thing is one requestAnimationFrame loop that writes to the DOM and sleeps when everything is at rest, so moving the mouse never re-renders React. Use it on a handful of high-intent controls; across a dense interface the constant re-shaping becomes noise.
Curator’s note
Snapping Cursor, rebuilt in October. The first version needed framer-motion and only knew mouse hover; this one reads the target's real corner radius, leans toward the pointer, follows keyboard focus and answers touch, with nothing but React.
