Fluid Cursor
A custom cursor: a dot rides the pointer while a trailing ring warps with its velocity and morphs on hover into a hand, arrow, text or view.
A free, copy-paste mouse effects component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- The ring trails the pointer with spring-lag
- It warps — stretching along its direction of travel
- Magnetically locks onto buttons and links
Dependency
This component uses motion for its animation. Install it, then drop the file in.
npm install motionPoint the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/fluid-cursor.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/fluid-cursorHow to use
Mount FluidCursor once at the root of your app for a site-wide custom cursor, or pass a containerRef to scope it to a single section (which is how the preview above keeps it inside its box). It replaces the OS pointer with a dot that rides the cursor and a ring that trails behind and warps from its own velocity.
Any a, button or [data-cursor] element triggers the hover state — the ring grows, magnetically locks onto the element, and can show a label via data-cursor-label. Turn on blend to have it invert over whatever is behind it. It disables itself on touch / coarse-pointer devices and respects prefers-reduced-motion, so there's nothing to guard for.
Site-wide (mount once)
import { FluidCursor } from "@/components/harsh-ui/fluid-cursor";
export default function RootLayout({ children }) {
return (
<body>
{children}
<FluidCursor />
</body>
);
}Scoped to a section
Pass a containerRef and the cursor only takes over inside that element — perfect for a hero or a single feature.
const ref = useRef<HTMLDivElement>(null);
<section ref={ref} className="relative">
<FluidCursor containerRef={ref} />
{/* ... */}
</section>Effects + tuning
<FluidCursor
effect="arrow" // "ring" | "dot" | "blob" | "spotlight" | "arrow"
arrowShape="rounded" // "rounded" | "sharp"
size={48}
lag={0.9} // higher = laggier trail
warp={0.8} // higher = more velocity stretch
blend // invert over any background
/>Per-element morphs
data-cursor changes what the cursor becomes on hover: "hand" (1-finger pointer), "arrow", "text", "view", "grab", "plus" or "link". A bare data-cursor just grows/locks the ring, and data-cursor-label shows text.
<a data-cursor="hand">A link — points a finger</a>
<div data-cursor="view" data-cursor-label="Open">Gallery</div>
<input data-cursor="text" />
<button data-cursor data-cursor-label="Play">Grow + label</button>Colour · click bursts · animated · image
Recolour it, pop a lightweight burst on every click (ripple / confetti / spark), add a spinning dashed halo, or swap the whole thing for an image.
<FluidCursor
color="#a855f7"
clickEffect="confetti" // "ripple" | "confetti" | "spark" | "none"
animated // spinning dashed halo + gentle pulse
/>
// …or drop an image inside the ring:
<FluidCursor image="/avatar.png" size={44} />Demo
import { FluidCursorDemo } from "@/components/demos/fluid-cursor-demo";
const Demo = () => (
<div className="h-screen w-full">
<FluidCursorDemo />
</div>
);Props
Notes
- Built on Motion (useMotionValue / useSpring / useVelocity) — the same velocity-warp technique as Motion Reel. No GSAP.
- Per-element morphs (hand / arrow / text / view / grab / plus / link) cross-fade in on hover; the geometric shapes warp with velocity while the icon shapes stay steady for legibility.
- Optimised for smoothness: pointer moves only set motion values (no React re-render), and a lastTarget guard means moving within an element never re-renders either — hover state changes only on enter/leave.
- The ring is an ellipse rotated to its direction of travel; because an ellipse is 180°-symmetric and the stretch fades to a circle at rest, the atan2 angle wrap is never visible.
- Rendered through a portal — to document.body when global, or into the container when scoped — so it never gets clipped by an ancestor's overflow.
- The native cursor is hidden via a .hui-cursor-none class that also covers children, so buttons and links don't reveal the OS pointer.
- Disables itself on (hover: none) / (pointer: coarse) devices and drops the warp under prefers-reduced-motion.
- Click bursts are a handful of Motion divs that remove themselves after ~0.8s (max six on screen), and they render outside the blend layer so confetti keeps its true colours.
- In a grid card there's no pointer, so autoPlay drives a drifting phantom pointer and pops an occasional burst to keep it alive.
Source code
The complete fluid-cursor.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
useVelocity,
} from "motion/react";
import {
ArrowUpRight,
Eye,
Grab,
Plus,
Pointer,
TextCursor,
} from "lucide-react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* FluidCursor
* A pointer replacement: a dot rides the cursor while a ring trails behind with
* spring-lag and WARPS — it stretches along its direction of travel from its own
* velocity. Any element can change what the cursor *becomes* on hover through a
* `data-cursor` value (e.g. `data-cursor="hand"` → a pointing-finger cursor),
* and it can grow/lock magnetically and show a `data-cursor-label`.
*
* Mount once at your app root for a site-wide cursor, or pass `containerRef` to
* scope it to a single box (used by the docs demo). Zero dependencies beyond
* React + Motion — no GSAP. Auto-disables on touch / coarse pointers and honours
* prefers-reduced-motion.
*/
export type FluidCursorShape =
| "ring"
| "dot"
| "blob"
| "spotlight"
| "arrow"
| "hand"
| "text"
| "view"
| "grab"
| "plus"
| "link";
export type FluidCursorEffect = FluidCursorShape;
export type FluidCursorArrow = "sharp" | "rounded";
export type FluidCursorClick = "none" | "ripple" | "confetti" | "spark";
export type FluidCursorProps = {
effect?: FluidCursorEffect;
arrowShape?: FluidCursorArrow;
image?: string;
clickEffect?: FluidCursorClick;
animated?: boolean;
size?: number;
dotSize?: number;
color?: string;
lag?: number;
warp?: number;
blend?: boolean;
magnetic?: boolean;
hideSystemCursor?: boolean;
hoverSelector?: string;
containerRef?: RefObject<HTMLElement | null>;
autoPlay?: boolean;
className?: string;
};
const GEOMETRIC = new Set<FluidCursorShape>(["ring", "dot", "blob", "spotlight"]);
const SHAPES = new Set<string>([
"ring", "dot", "blob", "spotlight", "arrow",
"hand", "text", "view", "grab", "plus", "link",
]);
const HOVER_SCALE = 2.2;
const ANCHOR: Partial<Record<FluidCursorShape, [number, number]>> = {
arrow: [-10, -10],
hand: [-40, -12],
};
type Hover = { grow?: number; label?: string; shape?: FluidCursorShape };
export function FluidCursor({
effect = "ring",
arrowShape = "rounded",
image,
clickEffect = "ripple",
animated = false,
size = 40,
dotSize = 7,
color = "#ffffff",
lag = 0.7,
warp = 0.6,
blend = false,
magnetic = true,
hideSystemCursor = true,
hoverSelector = "a, button, [data-cursor]",
containerRef,
autoPlay = false,
className = "",
}: FluidCursorProps) {
const reduce = useReducedMotion();
const hoverCapable = useHoverCapable();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const scoped = !!containerRef;
const x = useMotionValue(-200);
const y = useMotionValue(-200);
const stiffness = Math.round((reduce ? 600 : 170) / lag);
const spring = { stiffness, damping: 22, mass: 0.6 };
const rx = useSpring(x, spring);
const ry = useSpring(y, spring);
const vx = useVelocity(rx);
const vy = useVelocity(ry);
const eff = reduce ? 0 : warp;
const speed = useTransform(() => Math.hypot(vx.get(), vy.get()));
const angle = useTransform(() => (Math.atan2(vy.get(), vx.get()) * 180) / Math.PI);
const stretch = useTransform(speed, [0, 1400], [1, 1 + eff], { clamp: true });
const squash = useTransform(speed, [0, 1400], [1, Math.max(0.35, 1 - eff * 0.7)], {
clamp: true,
});
const [visible, setVisible] = useState(false);
const [pressed, setPressed] = useState(false);
const [hover, setHover] = useState<Hover | null>(null);
const magnet = useRef<{ cx: number; cy: number } | null>(null);
const lastTarget = useRef<HTMLElement | null>(null);
const [bursts, setBursts] = useState<Burst[]>([]);
const burstId = useRef(0);
const spawnBurst = useCallback(
(px: number, py: number) => {
if (clickEffect === "none") return;
const id = ++burstId.current;
setBursts((b) => [
...b.slice(-5),
{ id, x: px, y: py, kind: clickEffect, color, parts: makeParts(clickEffect, color) },
]);
window.setTimeout(() => setBursts((b) => b.filter((x) => x.id !== id)), 820);
},
[clickEffect, color],
);
useEffect(() => {
if (autoPlay || !hoverCapable) return;
const el = scoped ? containerRef?.current : null;
if (scoped && !el) return;
const rectOf = () => (el ? el.getBoundingClientRect() : null);
const toScope = (cx: number, cy: number): [number, number] => {
const r = rectOf();
return scoped && r ? [cx - r.left, cy - r.top] : [cx, cy];
};
const move = (e: PointerEvent) => {
const [px, py] = toScope(e.clientX, e.clientY);
const m = magnet.current;
if (m) {
x.set(m.cx + (px - m.cx) * 0.18);
y.set(m.cy + (py - m.cy) * 0.18);
} else {
x.set(px);
y.set(py);
}
if (!scoped) setVisible(true);
};
const enter = () => setVisible(true);
const leave = () => {
setVisible(false);
setHover(null);
magnet.current = null;
lastTarget.current = null;
};
const down = (e: PointerEvent) => {
setPressed(true);
const [px, py] = toScope(e.clientX, e.clientY);
spawnBurst(px, py);
};
const up = () => setPressed(false);
const over = (e: PointerEvent) => {
const target = (e.target as HTMLElement)?.closest?.(hoverSelector) as HTMLElement | null;
if (target === lastTarget.current) return;
lastTarget.current = target;
if (!target) {
setHover(null);
magnet.current = null;
return;
}
const raw = target.dataset.cursor ?? "";
const label = target.dataset.cursorLabel;
const shape = SHAPES.has(raw) ? (raw as FluidCursorShape) : undefined;
if (shape) {
magnet.current = null;
setHover({ shape, label });
} else if (magnetic) {
const tr = target.getBoundingClientRect();
const [cx, cy] = toScope(tr.left + tr.width / 2, tr.top + tr.height / 2);
magnet.current = { cx, cy };
x.set(cx);
y.set(cy);
setHover({ grow: (Math.max(tr.width, tr.height) + 22) / size, label });
} else {
magnet.current = null;
setHover({ grow: HOVER_SCALE, label });
}
};
const listenEl: HTMLElement | Window = scoped && el ? el : window;
listenEl.addEventListener("pointermove", move as EventListener, { passive: true });
listenEl.addEventListener("pointerover", over as EventListener, { passive: true });
listenEl.addEventListener("pointerdown", down as EventListener);
listenEl.addEventListener("pointerup", up);
if (scoped && el) {
el.addEventListener("pointerenter", enter);
el.addEventListener("pointerleave", leave);
} else {
setVisible(true);
document.addEventListener("pointerleave", leave);
document.addEventListener("pointerenter", enter);
}
return () => {
listenEl.removeEventListener("pointermove", move as EventListener);
listenEl.removeEventListener("pointerover", over as EventListener);
listenEl.removeEventListener("pointerdown", down as EventListener);
listenEl.removeEventListener("pointerup", up);
if (scoped && el) {
el.removeEventListener("pointerenter", enter);
el.removeEventListener("pointerleave", leave);
} else {
document.removeEventListener("pointerleave", leave);
document.removeEventListener("pointerenter", enter);
}
};
}, [autoPlay, hoverCapable, scoped, containerRef, hoverSelector, magnetic, size, x, y, spawnBurst]);
useEffect(() => {
if (!autoPlay) return;
const el = containerRef?.current;
if (!el) return;
setVisible(true);
let raf = 0;
let t0: number | null = null;
const loop = (t: number) => {
if (t0 === null) t0 = t;
const e = (t - t0) / 1000;
const w = el.offsetWidth;
const h = el.offsetHeight;
x.set(w * (0.5 + 0.3 * Math.sin(e * 1.1)));
y.set(h * (0.5 + 0.24 * Math.sin(e * 1.9 + 1)));
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [autoPlay, containerRef, x, y]);
useEffect(() => {
if (!autoPlay || clickEffect === "none") return;
const id = window.setInterval(() => spawnBurst(x.get(), y.get()), 1900);
return () => window.clearInterval(id);
}, [autoPlay, clickEffect, spawnBurst, x, y]);
useEffect(() => {
if (!hideSystemCursor || !hoverCapable || autoPlay) return;
const el = scoped ? containerRef?.current : document.documentElement;
if (!el) return;
el.classList.add("hui-cursor-none");
return () => el.classList.remove("hui-cursor-none");
}, [hideSystemCursor, hoverCapable, autoPlay, scoped, containerRef]);
if (!mounted || !hoverCapable) return null;
const portalTarget = scoped ? containerRef?.current : document.body;
if (!portalTarget) return null;
const ink = color;
const morph = hover?.shape;
const shape: FluidCursorShape = morph ?? effect;
const isGeo = GEOMETRIC.has(shape);
const hasImage = !!image && !morph;
// Magnetic grow swells the cursor to cover its target. That reads as a blob
// for the geometric shapes, but scaling a glyph just makes a giant arrow, so
// glyph cursors morph into a blob for the duration instead.
const blob = !!hover?.grow && magnetic && !isGeo && !hasImage;
const [ax, ay] = blob || hasImage ? [-50, -50] : ANCHOR[shape] ?? [-50, -50];
const growScale = (hover?.grow ?? 1) * (pressed ? 0.82 : 1);
const showDot = effect === "ring" && !morph && !hasImage;
const dotOn = showDot && visible && !(hover?.grow && magnetic);
const showHalo = animated && !reduce && (isGeo || hasImage);
const pos = scoped ? "absolute" : "fixed";
const overlay = (
<>
<div
aria-hidden
className={`pointer-events-none z-[9998] ${pos} inset-0 overflow-hidden ${className}`}
style={{ mixBlendMode: blend ? "difference" : undefined }}
>
<motion.div
style={{ x: rx, y: ry }}
className="absolute left-0 top-0"
animate={{ opacity: visible ? 1 : 0 }}
transition={{ duration: 0.2 }}
>
<motion.div
style={{ x: `${ax}%`, y: `${ay}%` }}
animate={{ scale: growScale }}
transition={{ type: "spring", stiffness: 320, damping: 26, mass: 0.5 }}
>
<motion.div
className="relative grid place-items-center"
animate={showHalo ? { scale: [1, 1.1, 1] } : { scale: 1 }}
transition={
showHalo
? { duration: 2.4, repeat: Infinity, ease: "easeInOut" }
: { duration: 0 }
}
>
{showHalo && (
<motion.span
className="absolute rounded-full"
style={{
width: size * 1.7,
height: size * 1.7,
border: `1.5px dashed ${ink}`,
opacity: 0.5,
}}
animate={{ rotate: 360 }}
transition={{ duration: 6, repeat: Infinity, ease: "linear" }}
/>
)}
<AnimatePresence initial={false} mode="popLayout">
<motion.div
key={blob ? "magnet" : hasImage ? "image" : shape}
initial={{ opacity: 0, scale: 0.4 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.4 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="grid place-items-center"
>
{blob ? (
<div
className="rounded-full"
style={{
width: size,
height: size,
background: `color-mix(in srgb, ${ink} 16%, transparent)`,
border: `1.5px solid ${ink}`,
}}
/>
) : hasImage ? (
<motion.div
className="overflow-hidden rounded-full"
style={{
width: size,
height: size,
rotate: angle,
scaleX: stretch,
scaleY: squash,
border: effect === "ring" ? `2px solid ${ink}` : undefined,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image}
alt=""
draggable={false}
className="h-full w-full select-none object-cover"
/>
</motion.div>
) : isGeo ? (
<motion.div
style={{
width: size,
height: size,
rotate: angle,
scaleX: stretch,
scaleY: squash,
...geoStyle(shape, ink),
}}
/>
) : (
<div style={{ color: ink, lineHeight: 0 }}>
{icon(shape, size, ink, arrowShape)}
</div>
)}
{hover?.label && (
<motion.span
className="absolute whitespace-nowrap text-[10px] font-semibold uppercase tracking-wide"
style={{ color: ink }}
// undo the grow, or the label swells with the blob
animate={{ scale: 1 / growScale }}
transition={{ type: "spring", stiffness: 320, damping: 26, mass: 0.5 }}
>
{hover.label}
</motion.span>
)}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
</motion.div>
{showDot && (
<motion.div style={{ x, y }} className="absolute left-0 top-0">
<motion.div
className="-translate-x-1/2 -translate-y-1/2 rounded-full"
style={{ width: dotSize, height: dotSize, background: ink }}
animate={{ opacity: dotOn ? 1 : 0, scale: pressed ? 1.6 : 1 }}
transition={{ duration: 0.18 }}
/>
</motion.div>
)}
</div>
<div
aria-hidden
className={`pointer-events-none z-[9999] ${pos} inset-0 overflow-hidden`}
>
<AnimatePresence>
{bursts.map((b) => (
<BurstView key={b.id} burst={b} />
))}
</AnimatePresence>
</div>
</>
);
return createPortal(overlay, portalTarget);
}
function geoStyle(shape: FluidCursorShape, ink: string): React.CSSProperties {
switch (shape) {
case "dot":
return { borderRadius: 999, background: ink };
case "blob":
return { borderRadius: 999, background: ink, opacity: 0.35, filter: "blur(6px)" };
case "spotlight":
return {
borderRadius: 999,
background: `radial-gradient(circle, ${ink} 0%, transparent 70%)`,
opacity: 0.5,
transform: "scale(2.6)",
};
default:
return { borderRadius: 999, border: `2px solid ${ink}` };
}
}
function icon(
shape: FluidCursorShape,
size: number,
ink: string,
arrowShape: FluidCursorArrow,
): ReactNode {
const s = Math.round(size * 0.7);
switch (shape) {
case "arrow":
return <Arrow size={size} ink={ink} rounded={arrowShape === "rounded"} />;
case "hand":
return <Pointer size={s} strokeWidth={2} fill="currentColor" fillOpacity={0.12} />;
case "text":
return <TextCursor size={s} strokeWidth={2} />;
case "view":
return <Eye size={s} strokeWidth={2} />;
case "grab":
return <Grab size={s} strokeWidth={2} />;
case "plus":
return <Plus size={s} strokeWidth={2.2} />;
case "link":
return <ArrowUpRight size={s} strokeWidth={2.2} />;
default:
return null;
}
}
function Arrow({ size, ink, rounded }: { size: number; ink: string; rounded: boolean }) {
return (
<svg width={size} height={size} viewBox="0 0 20 20" style={{ overflow: "visible" }}>
<path
d="M2 2 L2 16 L6 12.2 L8.6 17.8 L11 16.7 L8.4 11.2 L14 11.2 Z"
fill={ink}
stroke={rounded ? ink : "none"}
strokeWidth={rounded ? size * 0.11 : 0}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
type ClickParticle = {
dx: number;
dy: number;
rot: number;
color: string;
size: number;
round: boolean;
};
type Burst = {
id: number;
x: number;
y: number;
kind: FluidCursorClick;
color: string;
parts: ClickParticle[];
};
const CONFETTI = ["#3b82f6", "#f43f5e", "#eab308", "#22c55e", "#a855f7", "#f97316"];
function makeParts(kind: FluidCursorClick, color: string): ClickParticle[] {
if (kind === "ripple" || kind === "none") return [];
const palette = [color, ...CONFETTI];
const spark = kind === "spark";
const n = spark ? 9 : 14;
return Array.from({ length: n }, (_, i) => {
const a = (i / n) * Math.PI * 2 + Math.random() * 0.6;
const dist = 32 + Math.random() * (spark ? 50 : 58);
return {
dx: Math.cos(a) * dist,
dy: Math.sin(a) * dist,
rot: Math.random() * 360,
color: spark ? color : palette[i % palette.length],
size: spark ? 2.5 : 4 + Math.random() * 5,
round: spark ? true : Math.random() > 0.5,
};
});
}
function BurstView({ burst }: { burst: Burst }) {
if (burst.kind === "ripple") {
return (
<motion.span
className="absolute rounded-full"
style={{
left: burst.x,
top: burst.y,
width: 16,
height: 16,
marginLeft: -8,
marginTop: -8,
border: `2px solid ${burst.color}`,
}}
initial={{ scale: 0.3, opacity: 0.7 }}
animate={{ scale: 4.5, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>
);
}
const gravity = burst.kind === "confetti" ? 24 : 0;
return (
<div className="absolute" style={{ left: burst.x, top: burst.y }}>
{burst.parts.map((p, i) => (
<motion.span
key={i}
className="absolute"
style={{
width: p.size,
height: p.size,
background: p.color,
borderRadius: p.round ? 999 : 2,
}}
initial={{ x: 0, y: 0, opacity: 1, rotate: 0 }}
animate={{ x: p.dx, y: p.dy + gravity, opacity: 0, rotate: p.rot }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
))}
</div>
);
}
function useHoverCapable() {
const [ok, setOk] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setOk(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
return ok;
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.