← Back to Crumbs
ExperimentsReactTypeScriptCanvas
Celebration Fireworks
Two fountain fireworks in the bottom corners with a message that crossfades from one line to the next - a small celebration for a job well done.
Live preview
Thank you for being here.
Code
celebration-fireworks.tsx
"use client";
import * as React from "react";
export interface CelebrationFireworksProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
messages: string[];
messageDuration?: number;
colors?: string[];
}
interface Spark {
x: number;
y: number;
prevX: number;
prevY: number;
vx: number;
vy: number;
life: number;
maxLife: number;
radius: number;
rotation: number;
spinSpeed: number;
color: string;
}
const DEFAULT_COLORS = ["#ff8c00", "#ff6f00", "#ffa500", "#ffb347", "#ffd700", "#ffec80"];
const GRAVITY = 480;
const HEIGHT_FRACTION = 0.65;
function randomBetween(min: number, max: number) {
return min + Math.random() * (max - min);
}
function drawStar(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
outerRadius: number,
rotation: number,
) {
const points = 4;
const innerRadius = outerRadius * 0.4;
ctx.beginPath();
for (let i = 0; i < points * 2; i++) {
const r = i % 2 === 0 ? outerRadius : innerRadius;
const angle = rotation + (Math.PI / points) * i;
const x = cx + Math.cos(angle) * r;
const y = cy + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
export function CelebrationFireworks({
messages,
messageDuration = 2600,
colors = DEFAULT_COLORS,
className = "",
...props
}: CelebrationFireworksProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const colorsRef = React.useRef(colors);
colorsRef.current = colors;
const [index, setIndex] = React.useState(0);
const [visible, setVisible] = React.useState(true);
React.useEffect(() => {
if (messages.length === 0) return;
const hideTimer = setTimeout(() => setVisible(false), messageDuration);
return () => clearTimeout(hideTimer);
}, [index, messages.length, messageDuration]);
React.useEffect(() => {
if (visible || messages.length === 0) return;
const advanceTimer = setTimeout(() => {
setIndex((i) => (i + 1) % messages.length);
setVisible(true);
}, 500);
return () => clearTimeout(advanceTimer);
}, [visible, messages.length]);
React.useEffect(() => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
let width = 0;
let height = 0;
let sparks: Spark[] = [];
let rafId = 0;
let lastSpawn = 0;
function resize() {
const rect = container!.getBoundingClientRect();
width = Math.max(rect.width, 1);
height = Math.max(rect.height, 1);
canvas!.width = width * dpr;
canvas!.height = height * dpr;
canvas!.style.width = `${width}px`;
canvas!.style.height = `${height}px`;
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function spawnFrom(originX: number, direction: 1 | -1) {
const palette = colorsRef.current;
const count = 4 + Math.floor(Math.random() * 3);
const baseSpeed = Math.sqrt(2 * GRAVITY * Math.max(height * HEIGHT_FRACTION, 10));
for (let i = 0; i < count; i++) {
const vy = -baseSpeed * randomBetween(0.85, 1.05);
const flightTime = (2 * Math.abs(vy)) / GRAVITY;
sparks.push({
x: originX,
y: height,
prevX: originX,
prevY: height,
vx: direction * Math.abs(vy) * randomBetween(0.18, 0.45),
vy,
life: 0,
maxLife: randomBetween(flightTime * 0.9, flightTime * 1.3),
radius: randomBetween(2.5, 5),
rotation: randomBetween(0, Math.PI * 2),
spinSpeed: randomBetween(-6, 6),
color: palette[Math.floor(Math.random() * palette.length)] ?? "#ff8c00",
});
}
}
let lastFrame = performance.now();
function frame(now: number) {
const dt = Math.min((now - lastFrame) / 1000, 0.05);
lastFrame = now;
if (now - lastSpawn > 30) {
spawnFrom(width * 0.06, 1);
spawnFrom(width * 0.94, -1);
lastSpawn = now;
}
ctx!.clearRect(0, 0, width, height);
sparks = sparks.filter((s) => {
s.life += dt;
if (s.life >= s.maxLife) return false;
s.prevX = s.x;
s.prevY = s.y;
s.vy += GRAVITY * dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.rotation += s.spinSpeed * dt;
const t = s.life / s.maxLife;
const alpha = Math.max(t < 0.15 ? t / 0.15 : 1 - (t - 0.15) / 0.85, 0);
ctx!.strokeStyle = s.color;
ctx!.lineWidth = Math.max(s.radius * 0.45, 1);
ctx!.globalAlpha = alpha * 0.5;
ctx!.beginPath();
ctx!.moveTo(s.prevX, s.prevY);
ctx!.lineTo(s.x, s.y);
ctx!.stroke();
ctx!.globalAlpha = alpha;
ctx!.fillStyle = s.color;
ctx!.shadowBlur = 10;
ctx!.shadowColor = s.color;
drawStar(ctx!, s.x, s.y, s.radius, s.rotation);
ctx!.fill();
ctx!.shadowBlur = 0;
ctx!.globalAlpha = 1;
return s.y < height + 40;
});
rafId = requestAnimationFrame(frame);
}
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(container);
rafId = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(rafId);
resizeObserver.disconnect();
};
}, []);
return (
<div ref={containerRef} {...props} className={["overflow-hidden", className].filter(Boolean).join(" ")}>
<div className="relative h-full w-full">
<canvas
ref={canvasRef}
className="pointer-events-none absolute inset-0 block h-full w-full"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Quicksand:wght@500;600;700&display=swap"
precedence="default"
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-10">
<p
className={`max-w-sm text-center text-3xl font-semibold text-[#1c1330] transition-opacity duration-500 dark:text-white ${
visible ? "opacity-100" : "opacity-0"
}`}
style={{
fontFamily: "'Quicksand', ui-rounded, system-ui, sans-serif",
}}
>
{messages[index] ?? ""}
</p>
</div>
</div>
</div>
);
}
Usage
usage.tsx
import { CelebrationFireworks } from "@/components/celebration-fireworks";
export function ThankYouOverlay() {
return (
<div className="pointer-events-none fixed inset-0 z-50">
<CelebrationFireworks
messages={["Thank you!", "You're the best.", "Made with care, for you."]}
colors={["#ff8c00", "#ffa500", "#ffd700"]}
/>
</div>
);
}Dependencies
None - just React and Tailwind.
Responsive
Fills whatever container you place it in and redraws on resize via a ResizeObserver, the same as Shooting Stars. Fountain height scales to the container's own height (about 65% of it) rather than a fixed pixel value, so it looks right whether it's a small card accent or a full-viewport overlay. For the 'bottom corners of the screen' effect the name implies, wrap it in a fixed inset-0 container yourself (see usage) so it overlays the whole viewport instead of just wherever it happens to be mounted in the page. The message font (Quicksand, free for commercial use) loads itself via a <link> tag rendered inside the component - React hoists it to <head> automatically, no separate <head> setup needed.
Dark mode
The message text switches between a dark plum and white via Tailwind's dark: variant so it stays legible over whatever's behind it. The firework colors themselves stay the same warm orange-to-gold range in both themes - they're meant to pop regardless.