import {useEffect, useRef, useState} from 'react'; import {LOGO_COLORS} from './Logo.jsx'; const DURATION_MS = 2800; const PIECES = {small: 70, big: 240}; const GRAVITY = 0.12; // The speeds are per frame at 60 Hz; a faster screen moves the same distance in the same time. const FRAME_MS = 1000 / 60; const reducedMotion = () => window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; // The logo colors of the chosen accent and the accent itself. function colors() { const style = getComputedStyle(document.documentElement); const logo = LOGO_COLORS.map(([name, hex]) => style.getPropertyValue(name).trim() || hex); const accent = style.getPropertyValue('--primary').trim(); return accent ? [...logo, accent] : logo; } // Small: one burst from the top center. Big: two cannons from the lower corners. function pieces(size, width, height) { const palette = colors(); const big = size === 'big'; return Array.from({length: PIECES[size]}, (_, i) => { const left = i % 2 === 0; const angle = big ? (left ? -Math.PI / 3 : -2 * Math.PI / 3) + (Math.random() - 0.5) * 0.6 : Math.PI / 2 + (Math.random() - 0.5) * 2.4; const speed = big ? 9 + Math.random() * 9 : 2 + Math.random() * 5; return { x: big ? (left ? 0 : width) : width / 2, y: big ? height : height * 0.1, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, spin: Math.random() * Math.PI, spinSpeed: (Math.random() - 0.5) * 0.3, w: 6 + Math.random() * 6, h: 3 + Math.random() * 4, color: palette[i % palette.length], }; }); } /** * Confetti over the whole page for about 3 s after mount, then onDone. Remount (a new key) to play again. * * @param size 'small' or 'big' */ export default function Confetti({size, onDone}) { const canvasRef = useRef(null); const [reduce] = useState(reducedMotion); // In a ref: a new function from a re-render must not restart the animation, and the latest one runs at the end. const onDoneRef = useRef(onDone); onDoneRef.current = onDone; useEffect(() => { const canvas = canvasRef.current; const context = canvas?.getContext('2d'); if (!context) return undefined; canvas.width = window.innerWidth; canvas.height = window.innerHeight; const all = pieces(size, canvas.width, canvas.height); const start = performance.now(); let last = start; let frame; const draw = (now) => { const elapsed = now - start; const steps = (now - last) / FRAME_MS; last = now; context.setTransform(1, 0, 0, 1, 0, 0); context.clearRect(0, 0, canvas.width, canvas.height); if (elapsed >= DURATION_MS) { onDoneRef.current?.(); return; } context.globalAlpha = 1 - elapsed / DURATION_MS; for (const piece of all) { piece.vy += GRAVITY * steps; piece.vx *= 0.99 ** steps; piece.x += piece.vx * steps; piece.y += piece.vy * steps; piece.spin += piece.spinSpeed * steps; if (piece.y > canvas.height + 20) continue; const cos = Math.cos(piece.spin); const sin = Math.sin(piece.spin); context.setTransform(cos, sin, -sin, cos, piece.x, piece.y); context.fillStyle = piece.color; context.fillRect(-piece.w / 2, -piece.h / 2, piece.w, piece.h * Math.abs(cos)); } frame = requestAnimationFrame(draw); }; frame = requestAnimationFrame(draw); return () => cancelAnimationFrame(frame); }, [size]); if (reduce) return null; return