A soft aurora glow that trails the pointer behind the content on screen blend, stretching toward travel direction and easing back to a circle at rest.
npx shadcn@latest add @paragon/cursor-glow"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* CursorGlow — a soft aurora blob that follows the pointer with spring lag,
* sitting BEHIND the content on `screen` blend so it lights the surface like a
* moving light source rather than a cursor. It stretches subtly in the
* direction of travel (velocity-aware) and eases back to a circle at rest.
*
* Wrap any content: `<CursorGlow>…surface…</CursorGlow>`. The glow layer is
* pointer-events-none and does not hide the native cursor (it is ambient, not a
* pointer replacement) unless `hideCursor` is set. Gated on fine-pointer
* devices. Reduced motion pins a static, centered glow.
*/
export interface CursorGlowProps extends React.ComponentProps<"div"> {
/** Glow color. Defaults to the primary token. */
color?: string;
/** Glow diameter in px. */
size?: number;
/** Glow opacity, 0–1. */
intensity?: number;
/** Also hide the native cursor within the surface. */
hideCursor?: boolean;
}
const GLOW_SPRING: SpringOptions = { stiffness: 150, damping: 22, mass: 1 };
export function CursorGlow({
color = "var(--color-primary)",
size = 260,
intensity = 0.5,
hideCursor = false,
className,
children,
...props
}: CursorGlowProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const reduced = useReducedMotion();
const x = useMotionValue(-9999);
const y = useMotionValue(-9999);
const gx = useSpring(x, GLOW_SPRING);
const gy = useSpring(y, GLOW_SPRING);
// Velocity-aware stretch: the lag between raw and sprung position implies
// travel direction; convert to a gentle scale skew.
const stretchX = useTransform([x, gx], ([rx, sx]: number[]) =>
reduced ? 1 : 1 + Math.min(0.28, Math.abs(rx - sx) / 380),
);
const stretchY = useTransform([y, gy], ([ry, sy]: number[]) =>
reduced ? 1 : 1 + Math.min(0.28, Math.abs(ry - sy) / 380),
);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !fine) return;
if (reduced) {
const rect = el.getBoundingClientRect();
x.set(rect.width / 2);
y.set(rect.height / 2);
setInside(true);
return;
}
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
};
const onEnter = () => setInside(true);
const onLeave = () => setInside(false);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
};
}, [fine, reduced, x, y]);
return (
<div
ref={hostRef}
className={cn(
"relative overflow-hidden",
fine && hideCursor && "[&_*]:cursor-none",
className,
)}
style={fine && hideCursor ? { cursor: "none" } : undefined}
{...props}
>
{fine && (
<motion.div
aria-hidden
className="pointer-events-none absolute top-0 left-0 z-0 rounded-full"
style={{
x: reduced ? x : gx,
y: reduced ? y : gy,
scaleX: stretchX,
scaleY: stretchY,
width: size,
height: size,
marginLeft: -size / 2,
marginTop: -size / 2,
background: `radial-gradient(circle at center, ${color} 0%, transparent 68%)`,
filter: "blur(28px)",
mixBlendMode: "screen",
opacity: inside ? intensity : 0,
transition: "opacity 300ms ease",
}}
/>
)}
<div className="relative z-10">{children}</div>
</div>
);
}