A studio portfolio row where the mouse pointer is replaced by a small black dot that trails slightly behind on a spring. Over a project card the dot swells into a round bubble with an arrow and that project's own call to action, such as 'View case' or 'Play reel', and the artwork zooms a touch. Pressing shrinks it. When no mouse is present the follower visits each card on its own so the effect is visible. On phones and other touch screens the follower stays out of the way and every card shows its action as an ordinary text link.
// Adapted from Motion Primitives (https://motion-primitives.com/docs/cursor)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";
const projects = [
{ id: "orbit", name: "Orbit Coffee", kind: "Identity and packaging", year: "2026", action: "View case", bg: "#fb7185", ink: "#4c0519", pale: "#ffe4e6" },
{ id: "ferry", name: "Ferry Lane Press", kind: "Editorial site", year: "2025", action: "Read story", bg: "#1d4ed8", ink: "#172554", pale: "#dbeafe" },
{ id: "plot", name: "Plot Nine", kind: "Garden app", year: "2025", action: "Play reel", bg: "#a3e635", ink: "#1a2e05", pale: "#f7fee7" },
];
type Project = (typeof projects)[number];
// How tightly the follower tracks the pointer. Lower stiffness trails further behind.
const SPRING = { stiffness: 480, damping: 36, mass: 0.5 };
const DOT = 14;
const BUBBLE = 104;
function Poster({ project, index }: { project: Project; index: number }) {
return (
<svg viewBox="0 0 300 360" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="h-full w-full">
<rect width="300" height="360" fill={project.bg} />
{index === 0 && (
<>
{[150, 115, 80, 45].map((r, at) => (
<circle key={r} cx="150" cy="360" r={r * 1.7} fill={at % 2 ? project.pale : project.ink} />
))}
<circle cx="222" cy="78" r="34" fill={project.pale} />
</>
)}
{index === 1 && (
<>
{Array.from({ length: 20 }).map((_, at) => (
<circle key={at} cx={45 + (at % 4) * 70} cy={50 + Math.floor(at / 4) * 66} r={at % 3 === 0 ? 26 : 14} fill={at % 3 === 0 ? project.pale : project.ink} />
))}
</>
)}
{index === 2 && (
<>
{Array.from({ length: 9 }).map((_, at) => (
<rect key={at} x={-160 + at * 62} y="-80" width="30" height="560" fill={at % 2 ? project.ink : project.pale} transform="rotate(28 150 180)" />
))}
<circle cx="150" cy="180" r="62" fill={project.bg} />
<circle cx="150" cy="180" r="30" fill={project.ink} />
</>
)}
</svg>
);
}
export default function CustomCursorFollowerWithViewLabelOnCards() {
const reduceMotion = useReducedMotion();
const zoneRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLAnchorElement | null)[]>([]);
const resume = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const [hover, setHover] = useState<number | null>(null);
const [real, setReal] = useState(false);
const [shown, setShown] = useState(false);
const [pressed, setPressed] = useState(false);
const placed = useRef(false);
const targetX = useMotionValue(0);
const targetY = useMotionValue(0);
const x = useSpring(targetX, SPRING);
const y = useSpring(targetY, SPRING);
// Demo: with no mouse around, the follower visits each card in turn.
useEffect(() => {
if (real || reduceMotion) return;
const zone = zoneRef.current;
if (!zone) return;
let step = 0;
const visit = () => {
if (document.hidden) return;
const card = cardRefs.current[step % projects.length];
if (!card) return;
const zoneBox = zone.getBoundingClientRect();
const box = card.getBoundingClientRect();
const wobble = step % 2 ? 0.42 : 0.58;
const nextX = box.left - zoneBox.left + box.width * wobble;
const nextY = box.top - zoneBox.top + box.height * (1 - wobble);
if (!placed.current) {
placed.current = true;
targetX.jump(nextX - 120);
targetY.jump(nextY + 60);
x.jump(nextX - 120);
y.jump(nextY + 60);
}
targetX.set(nextX);
targetY.set(nextY);
setShown(true);
setHover(step % projects.length);
step += 1;
};
const first = setTimeout(visit, 250);
const timer = setInterval(visit, 1700);
return () => {
clearTimeout(first);
clearInterval(timer);
};
}, [real, reduceMotion]);
useEffect(() => () => clearTimeout(resume.current), []);
const onMove = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch" || reduceMotion) return;
const box = event.currentTarget.getBoundingClientRect();
clearTimeout(resume.current);
if (!real) {
// First real movement: start under the pointer instead of flying in from the demo spot.
x.jump(event.clientX - box.left);
y.jump(event.clientY - box.top);
setHover(null);
setReal(true);
placed.current = false;
}
targetX.set(event.clientX - box.left);
targetY.set(event.clientY - box.top);
setShown(true);
};
const onLeave = (event: PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "touch") return;
setShown(false);
setHover(null);
clearTimeout(resume.current);
resume.current = setTimeout(() => setReal(false), 2500);
};
const big = hover !== null;
const size = big ? BUBBLE : DOT;
return (
<section className="bg-neutral-100 px-5 py-14 text-neutral-950 sm:px-8 sm:py-16">
<div className="mx-auto max-w-5xl">
<div className="flex flex-wrap items-end justify-between gap-4">
<h2 className="text-4xl font-black tracking-tight sm:text-6xl">Selected work</h2>
<p className="max-w-xs text-sm leading-6 text-neutral-600">Hallam and Voss is a two-person design studio. Move across the projects: the cursor becomes the call to action.</p>
</div>
<div
ref={zoneRef}
onPointerMove={onMove}
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={onLeave}
className={`relative mt-9 ${real ? "cursor-none" : ""}`}
>
<ul className="grid gap-5 sm:grid-cols-3">
{projects.map((project, index) => (
<li key={project.id}>
<a
href="#"
ref={(node) => {
cardRefs.current[index] = node;
}}
onPointerEnter={(event) => {
if (event.pointerType !== "touch" && real) setHover(index);
}}
onPointerMove={(event) => {
if (event.pointerType !== "touch" && hover !== index) setHover(index);
}}
onPointerLeave={() => setHover((value) => (value === index && real ? null : value))}
className={`group block rounded-3xl focus:outline-none focus-visible:ring-4 focus-visible:ring-neutral-950 focus-visible:ring-offset-4 focus-visible:ring-offset-neutral-100 ${real ? "cursor-none" : ""}`}
>
<div className="aspect-[16/10] overflow-hidden rounded-3xl sm:aspect-[5/6]">
<motion.div className="h-full w-full" animate={{ scale: hover === index && !reduceMotion ? 1.05 : 1 }} transition={{ type: "spring", stiffness: 140, damping: 20 }}>
<Poster project={project} index={index} />
</motion.div>
</div>
<div className="mt-4 flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-bold tracking-tight">{project.name}</h3>
<p className="text-sm text-neutral-600">{project.kind}</p>
</div>
<span className="font-mono text-sm text-neutral-600">{project.year}</span>
</div>
<span className="mt-2 inline-flex items-center gap-1 text-sm font-semibold underline decoration-neutral-400 underline-offset-4 group-hover:decoration-neutral-950">
{project.action}
</span>
</a>
</li>
))}
</ul>
{/* The follower. It never takes pointer events, so links under it stay clickable. */}
{!reduceMotion && (
<motion.div aria-hidden="true" className="pointer-events-none absolute left-0 top-0 z-10" style={{ x, y }}>
<motion.div
className="flex items-center justify-center overflow-hidden rounded-full bg-neutral-950 text-white shadow-xl shadow-neutral-950/30"
initial={false}
animate={{
width: size,
height: size,
x: -size / 2,
y: -size / 2,
opacity: shown ? 1 : 0,
scale: pressed ? 0.86 : 1,
}}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
>
<AnimatePresence mode="wait" initial={false}>
{big && (
<motion.span
key={hover}
className="flex flex-col items-center gap-1 whitespace-nowrap text-xs font-semibold"
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.14 }}
>
<svg viewBox="0 0 20 20" fill="none" className="h-4 w-4">
<path d="M6 14 14 6m0 0H7.5M14 6v6.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{projects[hover ?? 0].action}
</motion.span>
)}
</AnimatePresence>
</motion.div>
</motion.div>
)}
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/custom-cursor-follower-with-view-label-on-cards.json
Track the pointer position inside an area with a `pointermove` listener, write it to two motion values and position an absolutely placed element from them. Hide the native pointer with `cursor-none` on the same area. Springs on the two values give the trailing feel.
It has `pointer-events-none`, so the browser treats it as if it were not there for hit testing. Clicks, hovers and text selection go to whatever is underneath.
Each card sets a `hover` index on pointer enter and clears it on leave. The follower reads that index to pick its size and label, so no geometry maths is needed.
Pointer events report their type. Touch input is ignored by the follower, so a tap simply follows the link. The action text under each card gives touch users the same information.
Yes. Move the listeners to a wrapper around your page content and change the follower from `absolute` to `fixed`, using `clientX` and `clientY` directly. Keep it scoped if you have forms, where a normal text cursor is easier to use.
Adapted from Cursor in Motion Primitives, used under its open-source licence. Licences and credits
Related: Magnetic Button That Follows the Pointer, Spotlight Cards with Pointer Light, 3D Tilt Card with Parallax Layers and Glare.
More cursor effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.