Snapping Cursor
A cursor dot that latches onto interactive elements and takes their exact shape.
- Category
- Components
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Props
"use client";
import { useRef, useState } from "react";
import { motion, useMotionValue, useSpring } from "framer-motion";
export function CursorSnapTarget({
children,
idleSize = 10,
padding = 6,
showIdleDot = true,
}: {
children: React.ReactNode;
idleSize?: number;
padding?: number;
/** Keep the dot visible in open space. Off, it only appears when snapped. */
showIdleDot?: boolean;
}) {
const boxRef = useRef<HTMLDivElement>(null);
const [snapped, setSnapped] = useState(false);
const x = useMotionValue(0);
const y = useMotionValue(0);
const w = useMotionValue(idleSize);
const h = useMotionValue(idleSize);
const spring = { stiffness: 480, damping: 36, mass: 0.6 };
const sx = useSpring(x, spring);
const sy = useSpring(y, spring);
const sw = useSpring(w, spring);
const sh = useSpring(h, spring);
function handleMove(e: React.MouseEvent<HTMLDivElement>) {
// While snapped the cursor is pinned to the target's box, so free
// tracking is suspended until the pointer leaves it.
if (snapped) return;
const rect = boxRef.current?.getBoundingClientRect();
if (!rect) return;
x.set(e.clientX - rect.left - idleSize / 2);
y.set(e.clientY - rect.top - idleSize / 2);
w.set(idleSize);
h.set(idleSize);
}
// Delegated: any descendant marked data-cursor-snap becomes a target.
function handleOver(e: React.MouseEvent<HTMLDivElement>) {
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-cursor-snap]");
const host = boxRef.current?.getBoundingClientRect();
if (!el || !host) {
setSnapped(false);
return;
}
const target = el.getBoundingClientRect();
x.set(target.left - host.left - padding);
y.set(target.top - host.top - padding);
w.set(target.width + padding * 2);
h.set(target.height + padding * 2);
setSnapped(true);
}
return (
<div
ref={boxRef}
onMouseMove={handleMove}
onMouseOver={handleOver}
onMouseLeave={() => setSnapped(false)}
className="relative cursor-none"
>
{children}
<motion.div
aria-hidden
style={{ x: sx, y: sy, width: sw, height: sh }}
animate={{
borderRadius: snapped ? 8 : 999,
opacity: snapped || showIdleDot ? 1 : 0,
}}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="pointer-events-none absolute left-0 top-0 border border-blue-500 bg-blue-500/15"
/>
</div>
);
}
About this component
Most magnetic cursors scale up when they near a button and keep chasing the pointer underneath, which reads as a wobble rather than a lock. This one commits: on entering a target it adopts that element's measured bounding box and then stops tracking the pointer entirely until the pointer leaves. That suspension is the whole effect — because the cursor is genuinely pinned rather than converging, the result feels like a physical detent, and it doubles as a real affordance, since the outline states exactly which hit area is live. Width and height ride springs alongside x and y, so the circle-to-rectangle morph is one continuous motion rather than a crossfade between two shapes. Reach for it on a small number of high-intent controls; across a dense interface the constant re-shaping becomes noise, and this library's Cursor Label Follower is the better fit when the goal is naming targets rather than outlining them. Because it sets `cursor: none` and the indicator is decorative, keyboard focus styles have to stand on their own — the preview wires focus and blur to the same snap for exactly that reason.
Curator’s note
Built for viberdy 2.0. Suspending pointer tracking while snapped is the detail that separates this from a cursor that merely grows near a button.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Cursor Label Follower
componentsA contextual chip that trails the pointer and names whatever it is over.
Magnetic Button
componentsA button whose label leans toward the cursor with a springy pull.