Cursors & Pointer

Emoji Reaction Cursor

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.

Install

npx shadcn@latest add @paragon/emoji-reaction-cursor

emoji-reaction-cursor.tsx

"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>
  );
}