Spellbook by Startidea
← Catálogo
Cart dificultad S ·

Confetti minimalista al añadir al cart

Confetti vanilla sin dependencias que estalla desde el botón pulsado. 80 partículas, 1.5 segundos, cleanup automático. Pensado para cierres de microciclo: añadir al cart, completar onboarding, confirmar pago.

Visto en: TodoMerchandising · Configurador ↗ canvascelebraciónvanilla

Por qué vanilla y no canvas-confetti

canvas-confetti es la lib clásica (~6 KB). Funciona perfecta. Pero el Spellbook lo queremos sin deps externas por dos razones:

  1. 0 KB extra al bundle del producto donde lo usas.
  2. No riesgo de update que cambie API.

80 líneas de canvas + física básica son suficientes.

Código (completo)

type Origin = HTMLElement | { x: number; y: number };

export function spellConfetti(origin: Origin, opts = {}) {
  if (typeof window === "undefined") return;
  if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;

  const { count = 80, duration = 1500, speed = 14, spread = 50, size = 8 } = opts;
  const colors = ["#E63E73", "#F472B6", "#FBBF24", "#F5F0E6", "#10B981"];

  let cx, cy;
  if (origin instanceof HTMLElement) {
    const r = origin.getBoundingClientRect();
    cx = r.left + r.width / 2;
    cy = r.top + r.height / 2;
  } else { cx = origin.x; cy = origin.y; }

  const canvas = document.createElement("canvas");
  canvas.style.cssText = "position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9999";
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  document.body.appendChild(canvas);
  const ctx = canvas.getContext("2d");

  const spreadRad = (spread * Math.PI) / 180;
  const particles = Array.from({ length: count }, () => {
    const angle = -Math.PI / 2 + (Math.random() - 0.5) * spreadRad * 2;
    const v = speed * (0.6 + Math.random() * 0.6);
    return {
      x: cx, y: cy,
      vx: Math.cos(angle) * v,
      vy: Math.sin(angle) * v,
      rot: Math.random() * Math.PI * 2,
      vrot: (Math.random() - 0.5) * 0.4,
      size: size * (0.6 + Math.random() * 0.8),
      color: colors[Math.floor(Math.random() * colors.length)],
      shape: Math.random() < 0.7 ? "rect" : "circle",
      life: 1,
    };
  });

  const start = performance.now();
  const draw = (now) => {
    const t = (now - start) / duration;
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    if (t >= 1) { canvas.remove(); return; }
    for (const p of particles) {
      p.vx *= 0.985;
      p.vy = p.vy * 0.985 + 0.45;       // gravedad + fricción
      p.x += p.vx; p.y += p.vy;
      p.rot += p.vrot;
      p.life = t < 0.7 ? 1 : 1 - (t - 0.7) / 0.3;
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.rotate(p.rot);
      ctx.globalAlpha = Math.max(0, p.life);
      ctx.fillStyle = p.color;
      if (p.shape === "rect") ctx.fillRect(-p.size/2, -p.size/4, p.size, p.size/2);
      else { ctx.beginPath(); ctx.arc(0, 0, p.size/2, 0, Math.PI*2); ctx.fill(); }
      ctx.restore();
    }
    requestAnimationFrame(draw);
  };
  requestAnimationFrame(draw);
}

Uso

import { spellConfetti } from "./spell-confetti";

<button onClick={(e) => {
  addToCart();
  spellConfetti(e.currentTarget);  // ← desde el botón pulsado
}}>
  + Añadir al pedido
</button>

Cuándo NO usarlo

  • En errores (suena cínico)
  • En acciones repetitivas (en un drag-and-drop, deja de ser especial)
  • Sin prefers-reduced-motion check (incluido por defecto en el código)
Licencia MIT. Atribución no requerida pero apreciada. Encontraste un bug o tienes una mejora? Abre un issue.