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.
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:
- 0 KB extra al bundle del producto donde lo usas.
- 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-motioncheck (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.