Emoji Reaction Cursor
Cursors & Pointer

Emoji Reaction Cursor

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.

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