"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface LiquidCursorProps {
children: React.ReactNode;
/** Blob diameter in px. */
size?: number;
/** Number of trailing droplets that merge into the blob. */
trail?: number;
/** Blob colour. White + "difference" inverts whatever is underneath in both themes. */
color?: string;
blend?: React.CSSProperties["mixBlendMode"];
/** Elements the blob wraps around. */
targets?: string;
/** Hide the system cursor inside the area. */
hideNativeCursor?: boolean;
/** Blur radius of the goo filter. */
goo?: number;
className?: string;
}
interface Pt {
x: number;
y: number;
}
export function LiquidCursor({
children,
size = 26,
trail = 5,
color = "#ffffff",
blend = "difference",
targets = "a, button, [data-cursor]",
hideNativeCursor = true,
goo = 7,
className,
}: LiquidCursorProps) {
const reduce = useReducedMotion() ?? false;
const scopeRef = React.useRef<HTMLDivElement>(null);
const blobRef = React.useRef<HTMLDivElement>(null);
const labelRef = React.useRef<HTMLSpanElement>(null);
const dotRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const filterId = `goo-${React.useId().replace(/:/g, "")}`;
const [enabled, setEnabled] = React.useState(false);
const state = React.useRef({
pointer: null as Pt | null,
target: null as Element | null,
label: "",
visible: false,
});
React.useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const upd = () => setEnabled(mq.matches);
upd();
mq.addEventListener("change", upd);
return () => mq.removeEventListener("change", upd);
}, []);
React.useEffect(() => {
const scope = scopeRef.current;
const blob = blobRef.current;
if (!enabled || !scope || !blob) return;
const count = reduce ? 0 : trail;
const pos: Pt = { x: -100, y: -100 };
const dim = { w: size, h: size, r: size / 2 };
const dots: Pt[] = Array.from({ length: count }, () => ({ x: -100, y: -100 }));
let last = { x: -100, y: -100 };
let raf = 0;
let running = true;
let opacity = 0;
let magnet = 0;
const onMove = (e: PointerEvent) => {
if (e.pointerType === "touch") return;
const r = scope.getBoundingClientRect();
const p = { x: e.clientX - r.left, y: e.clientY - r.top };
if (!state.current.pointer) {
pos.x = p.x;
pos.y = p.y;
dots.forEach((d) => ((d.x = p.x), (d.y = p.y)));
}
state.current.pointer = p;
const hit = (e.target as Element | null)?.closest?.(targets);
const t = hit && scope.contains(hit) && hit.getAttribute("data-cursor") !== "none" ? hit : null;
state.current.target = t;
state.current.label = t?.getAttribute("data-cursor-label") ?? "";
state.current.visible = true;
};
const onLeave = () => {
state.current.visible = false;
state.current.target = null;
};
const onDown = () => blob.animate([{ scale: "1" }, { scale: "0.82" }, { scale: "1" }], { duration: 260, easing: "ease-out" });
scope.addEventListener("pointermove", onMove);
scope.addEventListener("pointerleave", onLeave);
scope.addEventListener("pointerdown", onDown);
const io = new IntersectionObserver(([e]) => {
running = e.isIntersecting;
cancelAnimationFrame(raf);
if (running) {
prev = performance.now();
raf = requestAnimationFrame(loop);
}
});
io.observe(scope);
let prev = performance.now();
const loop = (now: number) => {
if (!running) return;
const f = Math.min(3, (now - prev) / 16.67);
prev = now;
const s = state.current;
const p = s.pointer;
const scopeRect = scope.getBoundingClientRect();
let goalX = p?.x ?? pos.x;
let goalY = p?.y ?? pos.y;
let goalW = size;
let goalH = size;
let goalR = size / 2;
const hasLabel = !!s.label;
if (s.target && p) {
const tr = s.target.getBoundingClientRect();
const cx = tr.left - scopeRect.left + tr.width / 2;
const cy = tr.top - scopeRect.top + tr.height / 2;
if (hasLabel) {
goalW = goalH = size * 3;
goalR = goalW / 2;
} else {
const pad = 6;
goalW = tr.width + pad * 2;
goalH = tr.height + pad * 2;
const br = parseFloat(getComputedStyle(s.target).borderTopLeftRadius) || 8;
goalR = Math.min(goalH / 2, br + pad);
// Magnetic pull: stay mostly centred, lean toward the pointer.
goalX = cx + (p.x - cx) * 0.12;
goalY = cy + (p.y - cy) * 0.12;
}
}
magnet += ((s.target && !hasLabel ? 1 : 0) - magnet) * 0.2 * f;
const k = reduce ? 1 : 1 - Math.pow(1 - 0.28, f);
const kd = reduce ? 1 : 1 - Math.pow(1 - 0.2, f);
pos.x += (goalX - pos.x) * k;
pos.y += (goalY - pos.y) * k;
dim.w += (goalW - dim.w) * kd;
dim.h += (goalH - dim.h) * kd;
dim.r += (goalR - dim.r) * kd;
opacity += ((s.visible ? 1 : 0) - opacity) * 0.2 * f;
// Squash & stretch along the velocity when free.
const vx = pos.x - last.x;
const vy = pos.y - last.y;
last = { x: pos.x, y: pos.y };
const speed = Math.min(40, Math.hypot(vx, vy));
const stretch = reduce ? 0 : (speed / 40) * 0.45 * (1 - magnet);
const ang = Math.atan2(vy, vx);
blob.style.width = `${dim.w}px`;
blob.style.height = `${dim.h}px`;
blob.style.borderRadius = `${dim.r}px`;
// Over a target the blob becomes a soft inverted plate rather than a solid fill.
blob.style.opacity = `${opacity * (1 - magnet * 0.74)}`;
blob.style.transform = `translate(${pos.x - dim.w / 2}px, ${pos.y - dim.h / 2}px) rotate(${ang}rad) scale(${1 + stretch}, ${1 - stretch * 0.6}) rotate(${-ang}rad)`;
if (labelRef.current) {
const l = labelRef.current;
l.textContent = s.label;
l.style.opacity = hasLabel ? `${opacity}` : "0";
l.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
}
// Trail follows the blob, each droplet chasing the one before it.
let lead: Pt = pos;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const kk = 1 - Math.pow(1 - (0.42 - i * 0.04), f);
d.x += (lead.x - d.x) * kk;
d.y += (lead.y - d.y) * kk;
lead = d;
const el = dotRefs.current[i];
if (!el) continue;
const ds = size * (0.8 - i * 0.1) * (1 - magnet * 0.9);
el.style.width = el.style.height = `${Math.max(0, ds)}px`;
el.style.opacity = `${opacity}`;
el.style.transform = `translate(${d.x - ds / 2}px, ${d.y - ds / 2}px)`;
}
raf = requestAnimationFrame(loop);
};
return () => {
running = false;
cancelAnimationFrame(raf);
io.disconnect();
scope.removeEventListener("pointermove", onMove);
scope.removeEventListener("pointerleave", onLeave);
scope.removeEventListener("pointerdown", onDown);
};
}, [enabled, reduce, size, trail, targets]);
return (
<div
ref={scopeRef}
className={cn("relative isolate", enabled && hideNativeCursor && "cursor-none [&_*]:cursor-none", className)}
>
{children}
{enabled && (
<div aria-hidden className="pointer-events-none absolute inset-0 z-50 overflow-hidden" style={{ mixBlendMode: blend }}>
<svg className="absolute size-0" aria-hidden focusable="false">
<defs>
<filter id={filterId}>
<feGaussianBlur in="SourceGraphic" stdDeviation={goo} result="b" />
<feColorMatrix in="b" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 22 -9" result="g" />
<feComposite in="SourceGraphic" in2="g" operator="atop" />
</filter>
</defs>
</svg>
<div className="absolute inset-0" style={{ filter: reduce ? undefined : `url(#${filterId})` }}>
<div ref={blobRef} className="absolute top-0 left-0 will-change-transform" style={{ background: color, opacity: 0 }} />
{Array.from({ length: reduce ? 0 : trail }, (_, i) => (
<div
key={i}
ref={(el) => {
dotRefs.current[i] = el;
}}
className="absolute top-0 left-0 rounded-full will-change-transform"
style={{ background: color, opacity: 0 }}
/>
))}
</div>
<span
ref={labelRef}
className="absolute top-0 left-0 text-[11px] font-semibold tracking-wider whitespace-nowrap uppercase"
style={{ color: "#000", opacity: 0 }}
/>
</div>
)}
</div>
);
}