lila/apps/web/src/components/ui/ConfettiBurst.tsx
2026-04-19 19:25:55 +02:00

103 lines
2.5 KiB
TypeScript

import { useEffect, useMemo, useState, useId } from "react";
type ConfettiBurstProps = {
className?: string;
colors?: string[];
count?: number;
};
type Piece = {
id: number;
style: React.CSSProperties & ConfettiVars;
};
type ConfettiVars = {
["--x0"]: string;
["--y0"]: string;
["--x1"]: string;
["--y1"]: string;
};
const hashStringToUint32 = (value: string) => {
// FNV-1a 32-bit
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
const mulberry32 = (seed: number) => {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
export const ConfettiBurst = ({
className,
colors = [
"var(--color-primary)",
"var(--color-accent)",
"var(--color-primary-light)",
"var(--color-accent-light)",
],
count = 18,
}: ConfettiBurstProps) => {
const [visible, setVisible] = useState(true);
const instanceId = useId();
useEffect(() => {
const t = window.setTimeout(() => setVisible(false), 1100);
return () => window.clearTimeout(t);
}, []);
const pieces = useMemo<Piece[]>(() => {
const seed = hashStringToUint32(`${instanceId}:${count}:${colors.join(",")}`);
const rand = mulberry32(seed);
const rnd = (min: number, max: number) => min + rand() * (max - min);
return Array.from({ length: count }).map((_, i) => {
const x0 = rnd(-6, 6);
const y0 = rnd(-6, 6);
const x1 = rnd(-160, 160);
const y1 = rnd(60, 220);
const delay = rnd(0, 120);
const rotate = rnd(0, 360);
const color = colors[i % colors.length];
return {
id: i,
style: {
left: "50%",
top: "0%",
backgroundColor: color,
transform: `translate(${x0}px, ${y0}px) rotate(${rotate}deg)`,
animationDelay: `${delay}ms`,
// consumed by keyframes
["--x0"]: `${x0}px`,
["--y0"]: `${y0}px`,
["--x1"]: `${x1}px`,
["--y1"]: `${y1}px`,
},
};
});
}, [colors, count, instanceId]);
if (!visible) return null;
return (
<div
className={`pointer-events-none absolute inset-0 overflow-visible ${className ?? ""}`}
aria-hidden
>
{pieces.map((p) => (
<span key={p.id} className="lila-confetti-piece" style={p.style} />
))}
</div>
);
};