Clicking fires a confetti-style burst of emoji glyphs that launch outward on seeded velocity vectors, arc back under gravity while spinning and fading, with a stagger and cooldown.
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 fires a small confetti-style burst of glyphs
* from the pointer. Each particle launches outward-and-up on a seeded velocity
* vector, then arcs back down under gravity — spinning, and fading as it falls —
* with a tiny per-particle stagger so the blast reads as a scatter rather than a
* single pop. Bursts are capped and self-clean, and a short cooldown prevents
* spam. 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 → a single gentle glyph fades in
* place, no travel. Trajectories are seeded by a monotonic counter — no
* Math.random in render, hydration-safe.
*/
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;
/** Burst power — scales launch velocity (and so arc height/reach). */
power?: number;
/** Min ms between bursts. */
cooldown?: number;
}
interface Particle {
id: number;
ox: number; // launch origin x (px, within host)
oy: number; // launch origin y
vx: number; // initial horizontal velocity (px/s)
vy: number; // initial vertical velocity (px/s, negative = up)
spin: number; // total rotation over life (deg)
rot0: number; // starting rotation (deg)
delay: number; // stagger before this shard launches (s)
life: number; // total flight time (s)
glyph: string;
}
const DEFAULT_GLYPHS = ["🎉", "✨", "💜", "🚀", "👏"];
const MAX_LIVE = 60;
const GRAVITY = 1500; // px/s² — the fall
const STEPS = 7; // keyframe samples along each parabola
// 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);
}
/**
* Sample a particle's ballistic path (launch velocity + constant gravity) into
* keyframe arrays motion/react can tween. Positions are relative to the origin;
* times are normalized 0..1 across the particle's life.
*/
function trajectory(p: Particle) {
const xs: number[] = [];
const ys: number[] = [];
const times: number[] = [];
for (let i = 0; i <= STEPS; i++) {
const f = i / STEPS;
const t = f * p.life;
xs.push(p.ox + p.vx * t);
// Slight horizontal drag near the end so shards don't sail off flat.
ys.push(p.oy + p.vy * t + 0.5 * GRAVITY * t * t);
times.push(f);
}
return { xs, ys, times };
}
export function EmojiReactionCursor({
glyphs = DEFAULT_GLYPHS,
count = 14,
size = 22,
power = 1,
cooldown = 240,
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);
const timersRef = React.useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
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);
}, []);
// Clean every pending removal timer on unmount — no dangling setState.
React.useEffect(() => {
const timers = timersRef.current;
return () => {
timers.forEach(clearTimeout);
timers.clear();
};
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !fine) return;
const glyphSet = glyphs.length ? glyphs : DEFAULT_GLYPHS;
const timers = timersRef.current;
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;
if (reduced) {
// Reduced motion: a single gentle glyph fades in place — no blast.
const id = base;
const shard: Particle = {
id,
ox: px,
oy: py,
vx: 0,
vy: 0,
spin: 0,
rot0: 0,
delay: 0,
life: 0,
glyph: glyphSet[Math.floor(seeded(id * 9.31) * glyphSet.length)],
};
seedRef.current += 1;
setParticles((prev) => [...prev, shard].slice(-MAX_LIVE));
const t = setTimeout(() => {
timers.delete(t);
setParticles((prev) => prev.filter((q) => q.id !== id));
}, 700);
timers.add(t);
return;
}
const n = Math.min(24, Math.max(1, Math.round(count)));
// Launch shards into the upper half-disc (outward + up) with seeded
// jitter in angle, speed, spin and stagger — a real scatter, all seeded.
const burst: Particle[] = Array.from({ length: n }).map((_, i) => {
const s = base + i;
const spread = Math.PI * 0.92; // ~166° fan, biased upward
const angle =
-Math.PI / 2 + (i / Math.max(1, n - 1) - 0.5) * spread +
(seeded(s * 7.13) - 0.5) * 0.5;
const speed = (520 + seeded(s * 3.71) * 460) * power;
const life = 0.85 + seeded(s * 2.29) * 0.5;
return {
id: s,
ox: px,
oy: py,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
spin: (seeded(s * 5.17) - 0.5) * 620,
rot0: (seeded(s * 4.02) - 0.5) * 40,
// Small ramped stagger so shards leave the muzzle in quick succession.
delay: (i / n) * 0.09 + seeded(s * 6.44) * 0.03,
life,
glyph: glyphSet[Math.floor(seeded(s * 9.31) * glyphSet.length)],
};
});
seedRef.current += n;
// Cap live particles so click-happy users never build an unbounded tree.
setParticles((prev) => [...prev, ...burst].slice(-MAX_LIVE));
const ids = new Set(burst.map((b) => b.id));
const maxLife = Math.max(...burst.map((b) => b.delay + b.life));
const t = setTimeout(
() => {
timers.delete(t);
setParticles((prev) => prev.filter((q) => !ids.has(q.id)));
},
maxLife * 1000 + 120,
);
timers.add(t);
};
el.addEventListener("pointerdown", onDown);
return () => {
el.removeEventListener("pointerdown", onDown);
};
}, [fine, glyphs, count, cooldown, power, reduced]);
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) => {
if (reduced) {
// Single soft pop in place.
return (
<motion.span
key={p.id}
className="absolute top-0 left-0"
style={{ fontSize: size, lineHeight: 1 }}
initial={{ x: p.ox, y: p.oy, scale: 0.6, opacity: 0 }}
animate={{ x: p.ox, y: p.oy, scale: 1, opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={{ duration: 0.24, ease: [0.22, 1, 0.36, 1] }}
>
<span
style={{ marginLeft: -size / 2, display: "inline-block" }}
>
{p.glyph}
</span>
</motion.span>
);
}
const { xs, ys, times } = trajectory(p);
return (
<motion.span
key={p.id}
className="absolute top-0 left-0"
style={{ fontSize: size, lineHeight: 1 }}
initial={{
x: p.ox,
y: p.oy,
scale: 0.3,
opacity: 0,
rotate: p.rot0,
}}
animate={{
// The ballistic arc: x eases out (drag), y follows gravity,
// rotation spins linearly, opacity holds then fades on the
// way down, scale pops once at launch.
x: xs,
y: ys,
rotate: [p.rot0, p.rot0 + p.spin],
scale: [0.3, 1, 1],
opacity: [0, 1, 1, 0],
}}
exit={{ opacity: 0, transition: { duration: 0.15 } }}
transition={{
delay: p.delay,
x: { duration: p.life, ease: "easeOut", times },
y: { duration: p.life, ease: "linear", times },
rotate: { duration: p.life, ease: "linear" },
scale: {
duration: p.life,
times: [0, 0.14, 1],
ease: [0.34, 1.3, 0.64, 1],
},
opacity: {
duration: p.life,
times: [0, 0.08, 0.62, 1],
ease: "linear",
},
}}
>
<span
style={{ marginLeft: -size / 2, display: "inline-block" }}
>
{p.glyph}
</span>
</motion.span>
);
})}
</AnimatePresence>
</div>
)}
</div>
);
}