Clicking scatters a small, tasteful burst of emoji glyphs at the pointer that scale in, drift on seeded vectors, and settle out, with a cooldown to prevent spam.
npx shadcn@latest add @paragon/emoji-reaction-cursor"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/**
* EmojiReactionCursor — clicking scatters a small, tasteful burst of glyphs at
* the pointer that scale in, drift outward on deterministic vectors, and settle
* out. A tiny cooldown prevents spam; each burst is capped and self-cleans.
* Great for celebration moments, approvals, and playful feedback surfaces.
*
* Wrap any content: `<EmojiReactionCursor>…surface…</EmojiReactionCursor>`. The
* burst layer is pointer-events-none so it never blocks the click it reacts to.
* Gated on fine-pointer devices. Reduced motion → glyphs fade in place, no
* drift. Vectors are seeded by a monotonic counter — no Math.random in render.
*/
export interface EmojiReactionCursorProps extends React.ComponentProps<"div"> {
/** Glyphs to pick from, cycled deterministically per particle. */
glyphs?: string[];
/** Particles per click burst. */
count?: number;
/** Glyph font-size in px. */
size?: number;
/** Min ms between bursts. */
cooldown?: number;
}
interface Particle {
id: number;
x: number;
y: number;
dx: number;
dy: number;
rot: number;
glyph: string;
}
const DEFAULT_GLYPHS = ["🎉", "✨", "💜", "🚀", "👏"];
// Deterministic pseudo-random in [0,1) from an integer seed. Stable across
// renders, no Date.now/Math.random — safe for hydration.
function seeded(n: number) {
const s = Math.sin(n * 12.9898) * 43758.5453;
return s - Math.floor(s);
}
export function EmojiReactionCursor({
glyphs = DEFAULT_GLYPHS,
count = 6,
size = 20,
cooldown = 220,
className,
children,
...props
}: EmojiReactionCursorProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const reduced = useReducedMotion();
const [particles, setParticles] = React.useState<Particle[]>([]);
const seedRef = React.useRef(0);
const lastRef = React.useRef(0);
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;
const timers: ReturnType<typeof setTimeout>[] = [];
const n = Math.max(1, Math.round(count));
const glyphSet = glyphs.length ? glyphs : DEFAULT_GLYPHS;
const onDown = (e: PointerEvent) => {
const now = performance.now();
if (now - lastRef.current < cooldown) return;
lastRef.current = now;
const rect = el.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const base = seedRef.current;
const burst: Particle[] = Array.from({ length: n }).map((_, i) => {
const s = base + i;
// Fan the particles around a circle with seeded jitter in angle/dist.
const angle =
(i / n) * Math.PI * 2 + (seeded(s * 7.13) - 0.5) * 0.9 - Math.PI / 2;
const dist = 34 + seeded(s * 3.71) * 30;
return {
id: s,
x: px,
y: py,
dx: Math.cos(angle) * dist,
dy: Math.sin(angle) * dist - 12,
rot: (seeded(s * 5.17) - 0.5) * 44,
glyph: glyphSet[Math.floor(seeded(s * 9.31) * glyphSet.length)],
};
});
seedRef.current += n;
setParticles((prev) => [...prev, ...burst]);
const ids = new Set(burst.map((b) => b.id));
const t = setTimeout(() => {
setParticles((prev) => prev.filter((p) => !ids.has(p.id)));
}, 900);
timers.push(t);
};
el.addEventListener("pointerdown", onDown);
return () => {
el.removeEventListener("pointerdown", onDown);
timers.forEach(clearTimeout);
};
}, [fine, glyphs, count, cooldown]);
return (
<div
ref={hostRef}
className={cn("relative overflow-hidden", className)}
{...props}
>
{children}
{fine && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-50 select-none"
>
<AnimatePresence>
{particles.map((p) => (
<motion.span
key={p.id}
className="absolute top-0 left-0"
style={{ fontSize: size, lineHeight: 1 }}
initial={{
x: p.x,
y: p.y,
scale: 0.4,
opacity: 0,
rotate: 0,
}}
animate={
reduced
? { x: p.x, y: p.y, scale: 1, opacity: 1, rotate: 0 }
: {
x: p.x + p.dx,
y: p.y + p.dy,
scale: [0.4, 1.15, 1],
opacity: [0, 1, 1],
rotate: p.rot,
}
}
exit={{ opacity: 0, scale: 0.8, y: p.y + p.dy + 14 }}
transition={{
type: "spring",
stiffness: 380,
damping: 24,
mass: 0.6,
}}
>
<span style={{ marginLeft: -size / 2, display: "inline-block" }}>
{p.glyph}
</span>
</motion.span>
))}
</AnimatePresence>
</div>
)}
</div>
);
}