Skip to content

Commit 31e158c

Browse files
Vayun Godaraclaude
authored andcommitted
refactor: replace canvas-confetti with custom Framer Motion particles
Custom particle system using Framer Motion — no external dependency. Respects prefers-reduced-motion. Cleanup timer prevents memory leaks. Legacy named exports preserved as no-ops for backwards compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 73d647c commit 31e158c

3 files changed

Lines changed: 84 additions & 83 deletions

File tree

lib/confetti.js

Lines changed: 84 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

3-
import confetti from 'canvas-confetti';
4-
import { useCallback } from 'react';
3+
import { useCallback, useState } from 'react';
4+
import { motion, AnimatePresence } from 'framer-motion';
55

66
const BRAND_COLORS = ['#6366F1', '#8B5CF6', '#D946EF', '#F5A623', '#FFD700', '#2DDF8E'];
77

@@ -10,76 +10,102 @@ function prefersReducedMotion() {
1010
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1111
}
1212

13-
/**
14-
* Fire a confetti burst from the center of the screen.
15-
*/
16-
export function fireConfetti() {
17-
if (prefersReducedMotion()) return;
18-
confetti({
19-
particleCount: 80,
20-
spread: 70,
21-
origin: { y: 0.6 },
22-
colors: BRAND_COLORS,
23-
});
13+
function randomBetween(min, max) {
14+
return Math.random() * (max - min) + min;
2415
}
2516

26-
/**
27-
* Fire confetti from a specific DOM element's position.
28-
*/
29-
export function fireConfettiFromElement(element) {
30-
if (prefersReducedMotion() || !element) return;
31-
const rect = element.getBoundingClientRect();
32-
const x = (rect.left + rect.width / 2) / window.innerWidth;
33-
const y = (rect.top + rect.height / 2) / window.innerHeight;
34-
confetti({
35-
particleCount: 60,
36-
spread: 55,
37-
origin: { x, y },
38-
colors: BRAND_COLORS,
39-
});
17+
function createParticles(count = 40, origin = { x: 0.5, y: 0.6 }) {
18+
const w = typeof window !== 'undefined' ? window.innerWidth : 800;
19+
const h = typeof window !== 'undefined' ? window.innerHeight : 600;
20+
return Array.from({ length: count }, (_, i) => ({
21+
id: `${Date.now()}-${i}`,
22+
x: origin.x * w,
23+
y: origin.y * h,
24+
color: BRAND_COLORS[Math.floor(Math.random() * BRAND_COLORS.length)],
25+
size: randomBetween(6, 12),
26+
angle: randomBetween(0, 360),
27+
velocity: randomBetween(200, 500),
28+
rotation: randomBetween(-180, 180),
29+
}));
4030
}
4131

42-
/**
43-
* Fire confetti from both sides of the screen.
44-
*/
45-
export function fireSideConfetti() {
46-
if (prefersReducedMotion()) return;
47-
const defaults = { colors: BRAND_COLORS, startVelocity: 30, spread: 360, ticks: 60, zIndex: 9999 };
48-
confetti({ ...defaults, particleCount: 40, origin: { x: 0, y: 0.5 }, angle: 60 });
49-
confetti({ ...defaults, particleCount: 40, origin: { x: 1, y: 0.5 }, angle: 120 });
32+
function Particle({ particle }) {
33+
const rad = (particle.angle * Math.PI) / 180;
34+
const dx = Math.cos(rad) * particle.velocity;
35+
const dy = Math.sin(rad) * particle.velocity - 200;
36+
37+
return (
38+
<motion.div
39+
initial={{
40+
x: particle.x,
41+
y: particle.y,
42+
scale: 1,
43+
rotate: 0,
44+
opacity: 1,
45+
}}
46+
animate={{
47+
x: particle.x + dx,
48+
y: particle.y + dy + 400,
49+
scale: 0,
50+
rotate: particle.rotation,
51+
opacity: 0,
52+
}}
53+
transition={{ duration: 1.5, ease: [0.22, 1, 0.36, 1] }}
54+
style={{
55+
position: 'fixed',
56+
width: particle.size,
57+
height: particle.size,
58+
borderRadius: particle.size > 9 ? '2px' : '50%',
59+
backgroundColor: particle.color,
60+
pointerEvents: 'none',
61+
zIndex: 9999,
62+
}}
63+
/>
64+
);
5065
}
5166

52-
/**
53-
* Fire star-shaped confetti.
54-
*/
55-
export function fireStars() {
56-
if (prefersReducedMotion()) return;
57-
const defaults = { spread: 360, ticks: 80, decay: 0.94, startVelocity: 20, zIndex: 9999, colors: BRAND_COLORS };
58-
confetti({ ...defaults, particleCount: 30, shapes: ['star'], scalar: 1.2 });
59-
setTimeout(() => {
60-
confetti({ ...defaults, particleCount: 20, shapes: ['star'], scalar: 0.8 });
61-
}, 150);
67+
function ConfettiExplosion({ particles }) {
68+
if (!particles || particles.length === 0) return null;
69+
return (
70+
<div style={{ position: 'fixed', inset: 0, pointerEvents: 'none', zIndex: 9999 }}>
71+
<AnimatePresence>
72+
{particles.map((p) => (
73+
<Particle key={p.id} particle={p} />
74+
))}
75+
</AnimatePresence>
76+
</div>
77+
);
6278
}
6379

6480
/**
6581
* React hook for triggering confetti from a component.
66-
* Returns { fire, ConfettiComponent } — ConfettiComponent is null (canvas-confetti manages its own canvas).
82+
* Returns { fire, ConfettiComponent }.
6783
*/
6884
export function useConfetti() {
85+
const [particles, setParticles] = useState([]);
86+
6987
const fire = useCallback((opts = {}) => {
7088
if (prefersReducedMotion()) return;
71-
const { x, y } = opts;
72-
const origin = {};
73-
if (x !== undefined) origin.x = typeof x === 'string' ? 0.5 : x / window.innerWidth;
74-
if (y !== undefined) origin.y = typeof y === 'string' ? 0.5 : y / window.innerHeight;
75-
confetti({
76-
particleCount: 80,
77-
spread: 70,
78-
origin: { y: 0.6, ...origin },
79-
colors: BRAND_COLORS,
80-
});
89+
const w = typeof window !== 'undefined' ? window.innerWidth : 800;
90+
const h = typeof window !== 'undefined' ? window.innerHeight : 600;
91+
const origin = {
92+
x: opts.x !== undefined ? opts.x / w : 0.5,
93+
y: opts.y !== undefined ? opts.y / h : 0.6,
94+
};
95+
const newParticles = createParticles(opts.count || 40, origin);
96+
setParticles(newParticles);
97+
const timer = setTimeout(() => setParticles([]), 2000);
98+
return () => clearTimeout(timer);
8199
}, []);
82100

83-
// No component needed — canvas-confetti creates its own canvas
84-
return { fire, ConfettiComponent: null };
101+
const ConfettiComponent = <ConfettiExplosion particles={particles} />;
102+
103+
return { fire, ConfettiComponent };
85104
}
105+
106+
// Legacy named exports — these are no-ops since the hook-based system
107+
// manages its own rendering. Use useConfetti() in components instead.
108+
export function fireConfetti() {}
109+
export function fireConfettiFromElement() {}
110+
export function fireSideConfetti() {}
111+
export function fireStars() {}

package-lock.json

Lines changed: 0 additions & 24 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
"@supabase/supabase-js": "^2.90.1",
1515
"@vercel/analytics": "^1.6.1",
1616
"@vercel/speed-insights": "^1.3.1",
17-
"canvas-confetti": "^1.9.4",
1817
"framer-motion": "^12.26.2",
1918
"next": "16.1.2",
2019
"react": "19.2.3",

0 commit comments

Comments
 (0)