"use client"; import * as React from "react"; import {cn} from "@/lib/utilities"; import styles from "./fireworks-background.module.css"; const rand = (min: number, max: number): number => Math.random() * (max - min) + min; const randInt = (min: number, max: number): number => Math.floor(Math.random() * (max - min) + min); const randColor = (): string => `hsl(${randInt(0, 360)}, 100%, 50%)`; interface ParticleType { x: number; y: number; color: string; speed: number; direction: number; vx: number; vy: number; gravity: number; friction: number; alpha: number; decay: number; size: number; update: () => void; draw: (ctx: CanvasRenderingContext2D) => void; isAlive: () => boolean; } const createParticle = ( x: number, y: number, color: string, speed: number, direction: number, gravity: number, friction: number, size: number, ): ParticleType => { const vx = Math.cos(direction) * speed; const vy = Math.sin(direction) * speed; const alpha = 1; const decay = rand(0.005, 0.02); return { x, y, color, speed, direction, vx, vy, gravity, friction, alpha, decay, size, update() { this.vx *= this.friction; this.vy *= this.friction; this.vy += this.gravity; this.x += this.vx; this.y += this.vy; this.alpha -= this.decay; }, draw(ctx: CanvasRenderingContext2D) { ctx.save(); ctx.globalAlpha = this.alpha; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fillStyle = this.color; ctx.fill(); ctx.restore(); }, isAlive() { return this.alpha > 0; }, }; }; interface FireworkType { x: number; y: number; targetY: number; color: string; speed: number; size: number; angle: number; vx: number; vy: number; trail: Array<{x: number; y: number}>; trailLength: number; exploded: boolean; update: () => boolean; explode: () => void; draw: (ctx: CanvasRenderingContext2D) => void; } const createFirework = ( x: number, y: number, targetY: number, color: string, speed: number, size: number, particleSpeed: {min: number; max: number} | number, particleSize: {min: number; max: number} | number, onExplode: (particles: ReadonlyArray) => void, ): FireworkType => { const angle = -Math.PI / 2 + rand(-0.3, 0.3); const vx = Math.cos(angle) * speed; const vy = Math.sin(angle) * speed; const trail: Array<{x: number; y: number}> = []; const trailLength = randInt(10, 25); return { x, y, targetY, color, speed, size, angle, vx, vy, trail, trailLength, exploded: false, update() { this.trail.push({x: this.x, y: this.y}); if (this.trail.length > this.trailLength) { this.trail.shift(); } this.x += this.vx; this.y += this.vy; this.vy += 0.02; if (this.vy >= 0 || this.y <= this.targetY) { this.explode(); return false; } return true; }, explode() { const numberOfParticles = randInt(50, 150); const particles: Array = []; for (let index = 0; index < numberOfParticles; index += 1) { const particleAngle = rand(0, Math.PI * 2); const localParticleSpeed = getValueByRange(particleSpeed); const localParticleSize = getValueByRange(particleSize); particles.push(createParticle(this.x, this.y, this.color, localParticleSpeed, particleAngle, 0.05, 0.98, localParticleSize)); } onExplode(particles); }, draw(ctx: CanvasRenderingContext2D) { ctx.save(); ctx.beginPath(); if (this.trail.length > 1) { ctx.moveTo(this.trail[0]!.x, this.trail[0]!.y); for (const point of this.trail) { ctx.lineTo(point.x, point.y); } } else { ctx.moveTo(this.x, this.y); ctx.lineTo(this.x, this.y); } ctx.strokeStyle = this.color; ctx.lineWidth = this.size; ctx.lineCap = "round"; ctx.stroke(); ctx.restore(); }, }; }; const getValueByRange = (range: {min: number; max: number} | number): number => { if (typeof range === "number") { return range; } return rand(range.min, range.max); }; const getColor = (color: string | ReadonlyArray | undefined): string => { if (Array.isArray(color)) { return color[randInt(0, color.length)] ?? randColor(); } if (typeof color === "string") { return color; } return randColor(); }; /** Props accepted by {@link FireworksBackground}. */ export interface FireworksBackgroundProps extends Omit, "color"> { /** HTML attributes forwarded to the internal `` element. @default undefined */ canvasProps?: React.HTMLAttributes; /** Relative launch frequency multiplier for automatic fireworks. @default 1 */ population?: number; /** Fixed color or palette used for generated fireworks and particles. @default undefined */ color?: string | ReadonlyArray; /** Launch velocity or range used for ascending fireworks. @default {min: 4, max: 8} */ fireworkSpeed?: {min: number; max: number} | number; /** Stroke width or range used for the ascending firework trail. @default {min: 2, max: 5} */ fireworkSize?: {min: number; max: number} | number; /** Velocity or range used for explosion particles. @default {min: 2, max: 7} */ particleSpeed?: {min: number; max: number} | number; /** Size or range used for explosion particles. @default {min: 1, max: 5} */ particleSize?: {min: number; max: number} | number; } /** * Renders an auto-launching fireworks display inside a clickable background canvas. * * @remarks * - Animated component using the `motion` library * - Renders a `
` element containing a `` * - Styling via CSS Modules with `--ac-*` custom properties * - Client-side only (`"use client"` directive) * * @example * ```tsx * * ``` * * @see {@link FireworksBackgroundProps} for available props */ const FireworksBackground = React.forwardRef( ( { className, canvasProps, population = 1, color, fireworkSpeed = {min: 4, max: 8}, fireworkSize = {min: 2, max: 5}, particleSpeed = {min: 2, max: 7}, particleSize = {min: 1, max: 5}, ...props }, ref, ) => { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); React.useImperativeHandle(ref, () => containerRef.current!, []); React.useEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) { return; } const context = canvas.getContext("2d"); if (!context) { return; } let maxX = globalThis.window.innerWidth; let ratio = container.offsetHeight / Math.max(container.offsetWidth, 1); let maxY = maxX * ratio; let launchTimeoutId = 0; canvas.width = maxX; canvas.height = maxY; const setCanvasSize = (): void => { maxX = globalThis.window.innerWidth; ratio = container.offsetHeight / Math.max(container.offsetWidth, 1); maxY = maxX * ratio; canvas.width = maxX; canvas.height = maxY; }; globalThis.window.addEventListener("resize", setCanvasSize); const explosions: Array = []; const fireworks: Array = []; const handleExplosion = (particles: ReadonlyArray): void => { explosions.push(...particles); }; const launchFirework = (): void => { const x = rand(maxX * 0.1, maxX * 0.9); const y = maxY; const targetY = rand(maxY * 0.1, maxY * 0.4); const fireworkColor = getColor(color); const speed = getValueByRange(fireworkSpeed); const size = getValueByRange(fireworkSize); fireworks.push(createFirework(x, y, targetY, fireworkColor, speed, size, particleSpeed, particleSize, handleExplosion)); const timeout = rand(300, 800) / population; launchTimeoutId = globalThis.window.setTimeout(launchFirework, timeout); }; launchFirework(); let animationFrameId = 0; const animate = (): void => { context.clearRect(0, 0, maxX, maxY); for (let index = fireworks.length - 1; index >= 0; index -= 1) { const firework = fireworks[index]!; if (firework.update()) { firework.draw(context); } else { fireworks.splice(index, 1); } } for (let index = explosions.length - 1; index >= 0; index -= 1) { const particle = explosions[index]!; particle.update(); if (particle.isAlive()) { particle.draw(context); } else { explosions.splice(index, 1); } } animationFrameId = globalThis.requestAnimationFrame(animate); }; animate(); const handleClick = (event: MouseEvent): void => { const fireworkColor = getColor(color); const speed = getValueByRange(fireworkSpeed); const size = getValueByRange(fireworkSize); fireworks.push( createFirework(event.clientX, maxY, event.clientY, fireworkColor, speed, size, particleSpeed, particleSize, handleExplosion), ); }; container.addEventListener("click", handleClick); return () => { globalThis.window.removeEventListener("resize", setCanvasSize); container.removeEventListener("click", handleClick); globalThis.window.clearTimeout(launchTimeoutId); globalThis.cancelAnimationFrame(animationFrameId); }; }, [color, fireworkSize, fireworkSpeed, particleSize, particleSpeed, population]); return (
); }, ); FireworksBackground.displayName = "FireworksBackground"; export {FireworksBackground};