Ask our homepage how many canvases it has:
curl -s https://pub-trivia.app/ | grep -o '<canvas[^>]*>' # <canvas aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full "> # <canvas aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full "> Two, on a marketing page, because the demos on the homepage are the product's own screens rather than screenshots, and the product's screens have an effects layer. This is a post about that layer: nine files, 1,280 lines, of which one file is 510 and does all the heavy work.
The useful part is not the confetti. It is where the line between canvas and DOM got drawn, and the five small decisions that keep a particle system from being the thing that ruins a phone.
The brief, in the file's own words
* Canvas rather than DOM nodes because a phone in a busy pub is the target: a * few hundred divs animating at once stutters, a canvas does not. The loop only * runs while something is on screen, there is a hard cap on particles, and * nothing plays at all for a player who has asked for reduced motion. Six presets: confetti, cannons, rain, sparkles, bubbles and fireworks. One <canvas> that fills its positioned parent. The whole public surface is two methods:
export type ParticleCanvasHandle = { burst: (preset: ParticlePreset, options?: BurstOptions) => void /** Wipes everything on screen. */ clear: () => void } React owns the element, nothing else
The engine is a plain closure over a canvas, built once in an effect and destroyed on unmount. React never re-renders because of a particle:
useEffect(() => { if (!canvasRef.current) return engine.current = createEngine(canvasRef.current) return () => { engine.current?.destroy() engine.current = null } }, []) useImperativeHandle(ref, () => ({ burst: (preset, options) => engine.current?.burst(preset, options), clear: () => engine.current?.clear(), }), []) This is the one case where an imperative handle is obviously right rather than a smell. A burst is an event, not a state: there is no value of props that means "confetti happened just now", and encoding one means inventing a counter or a nonce to make the same event look like a different prop. Calling a method is what the caller actually wants to do.
The particle array is a module-free local, and a particle is a flat struct of nineteen fields with defaults supplied by one helper:
type Particle = { x: number; y: number; vx: number; vy: number size: number; color: string; shape: Shape rotation: number; spin: number /** Phase of the side-to-side flutter, and how fast it advances. */ wobble: number; wobbleSpeed: number; sway: number gravity: number; drag: number /** Terminal falling speed, so confetti drifts down instead of dropping. */ maxFall: number age: number; life: number /** Frames to wait before appearing. */ delay: number /** Fraction of its life after which a particle starts to fade. */ fadeFrom: number shrink: boolean /** Fireworks only: the colours this shell bursts into when it peaks. */ shell?: readonly string[] } No classes and no subtypes. A firework is a particle with a shell array, and when its life runs out it is replaced by the particles it explodes into. That is the entire inheritance model.
The five decisions that matter
One: the loop stops itself. The last line of the frame function is the whole performance story:
particles = next.length > MAX_PARTICLES ? next.slice(-MAX_PARTICLES) : next frame = particles.length > 0 ? requestAnimationFrame(step) : 0 No particles means no next frame, and frame goes back to 0. burst checks that and restarts the loop only if it is not already running:
if (frame === 0) { last = performance.now() frame = requestAnimationFrame(step) } So an idle canvas costs nothing. Not "nothing much": there is no callback registered at all. On a marketing page where the effects may never fire, the cost of those two canvas elements is two DOM nodes and one ResizeObserver each.
Two: device pixels are capped at two.
const dpr = Math.min(2, window.devicePixelRatio || 1) const rect = canvas.getBoundingClientRect() canvas.width = Math.round(rect.width * dpr) canvas.height = Math.round(rect.height * dpr) ctx.setTransform(dpr, 0, 0, dpr, 0, 0) A phone reporting 3 would make the backing store 2.25 times the area of a 2, for 110 pieces of confetti whose lives are 130 to 190 frames, which is two to three seconds. Capping at 2 is invisible and the setTransform means every number in the drawing code stays in CSS pixels, which is the part that actually saves you grief. The resize runs from a ResizeObserver on the canvas itself, not a window listener, because the thing that changes size is a panel inside a page.
Three: the timestep is normalised and capped.
// Normalised to 60fps and capped, so a tab that was in the background // does not fire everything off screen in one giant step. const dt = Math.min(3, (now - last) / FRAME_MS) Every physics line is then written in frames rather than seconds, which keeps the tuning numbers readable (gravity: 0.3 instead of gravity: 1080), and the cap at three frames is what stops a backgrounded tab from resuming with a 4,000ms delta and teleporting everything into the void. Drag has to be exponentiated to stay correct under a variable step:
const drag = Math.pow(p.drag, dt) p.vx *= drag p.vy = Math.min(p.maxFall, p.vy * drag + p.gravity * dt) Four: a hard cap, applied twice. MAX_PARTICLES is 700, and the slice(-700) happens both when a burst is added and at the end of every frame. A caller can ask for ten bursts in a second and the worst case is bounded. Particles are also culled the moment they leave the box rather than at end of life:
// Gone off the bottom or the sides: drop it now rather than at end of life. if ((p.y > h + 60 && p.vy > 0) || p.x < -120 || p.x > w + 120) continue Five: the burst scales to its container. The same call has to look right in a 300-pixel card on a marketing page and on a television across a room, so every speed and size is multiplied by a factor derived from the canvas height:
/** Speeds scale with the canvas so a small card and a TV both look right. */ const k = Math.min(1.3, Math.max(0.5, Math.sqrt(h / 700))) Square root rather than linear, clamped at both ends. Linear scaling makes a big screen's confetti look like it is falling through syrup, because perceived speed does not grow with the size of the frame.
The reduced-motion check is inside burst, on purpose
burst(preset, options = {}) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return if (w === 0 || h === 0) return // ... } The obvious implementation reads that media query once, at mount, and stores a boolean. This reads it on every call, which costs a cheap lookup and buys something real: a visitor who turns the setting on in their operating system gets the new behaviour on the next burst, with no remount and no listener to wire up. For a page a user keeps open all evening, that is the difference between a preference and a preference that needed a refresh.
Note also the second guard. A canvas with no measured size yet silently swallows the burst instead of spawning 200 particles at coordinate zero, which is what you get if you let 0.5 * w run before the first layout.
What stayed in the DOM
The other eight files in that directory are not canvas at all. The podium, the trophy, the score counters, the streak flame and the countdown ring are SVG and DOM, animated with framer-motion. The dividing line turned out to be simple once it was written down:
Canvas gets hundreds of identical, short-lived, unlabelled things that nobody will ever need to select, read out or inspect. DOM gets the few large, persistent, meaningful things, because those have text in them, they need to be in the accessibility tree, and animating six elements with a spring is not a performance problem.
So the podium is three columns with staggered start times:
/** Seconds after mount that each column starts to rise. */ const RISE_AT = { 3: 0.2, 2: 1.0, 1: 1.9 } as const Third place first, then second, then first, with a pause before each so a room has time to react. That is a presentation decision, and it belongs somewhere a person can read it, not inside a particle struct.
The trophy borrows a helper from an unexpected place:
import { useSvgId } from '@/components/mascot/parts' That is the id sanitiser from the mascot components, which exists because React's useId can return characters that are not valid inside a url(#...) reference. Two independent drawing systems, one shared three-line utility, which is about the right amount of sharing.
Stars are generated rather than drawn, once, and used by both the medals and the canvas:
/** An n-pointed star as an SVG path. */ export function starPath(cx: number, cy: number, outer: number, inner: number, points = 5): string One accidental finding about our palette
Earlier this month I audited the colours in this repository and reported that #F5A623 appeared in exactly one file, the generator for link-preview images, and nowhere else. That is no longer true. It is now in six places:
lib/og/og-image.tsx const BRAND = "#F5A623" components/mascot/Froth.tsx const AMBER = '#F5A623' components/vfx/Trophy.tsx linear-gradient(to bottom, #FFE27A, #F5A623) components/vfx/ParticleCanvas.tsx CONFETTI_COLORS, GOLD_COLORS, FOAM_COLORS The orphan hex became the accent colour of an entire effects layer. And it is still not in the stylesheet: app/globals.css contains zero hex values, only HSL triplets, because the theme is tokens and the effects are not. Whether that is a reasonable split or a slow drift is a genuine question, and the honest answer is that the first drawing that needed an amber did not find one in the token file, so it declared its own.
The one thing I would change
Every frame builds a new array:
const next: Particle[] = [] for (const p of particles) { /* ... */ next.push(p) } particles = next The particle objects are reused, so the garbage is one array per frame, not 700 objects. At 60fps that is 60 small arrays a second while something is in flight, and never when it is not. It has not shown up in any measurement, so it stays, but an in-place compaction with a live count is the obvious next move if it ever does. Writing that down is cheaper than pre-optimising it.
See it yourself
Open pub-trivia.app and let the big-screen demo run to the end of its round. The podium rises, and the canvas over the top of it fires. Both canvases are in the markup from the first byte, which you can confirm with the curl at the top of this post, and in DevTools you can watch the animation frames appear only while something is in flight.
Then turn on reduced motion in your operating system and reload. The demo still runs and the podium still arrives, because framer-motion's reducedMotion="user" keeps opacity changes and drops the transforms. The canvas fires nothing at all, because of that one line inside burst.
If you want the version with a real quiz night behind it rather than a scripted one, the free tier needs no card: pub-trivia.app.

