Shots in this scene
Build: The Launch Confetti
In one line
Reserving a Volt now throws a pooled canvas burst of at most 150 pieces, which pauses with the tab and never plays for reduced motion, and the Reserve button's gradient border turns twice with @property.
Before you start
You will learn
- Add
src/js/components/confetti.js, exportingburst(x, y) - Add the Phase 17 block to
components.cssand the message paragraph toindex.html - Call
burstfrom the Reserve handler inmain.js, then test reduced motion
You write code in
In your lumen-motion project (npm run dev) and in the editor on this page: add one file and change three
Files you touch
- src/js/components/confetti.js
- index.html
- src/styles/components.css
- src/js/main.js
Think of it like this
The launch party popper. One pull, a handful of paper, gone in two seconds, and no popper if you asked for a quiet room. A second pull while the paper is in the air does not make a blizzard.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- pool
- The fixed array of 150 particle objects that
burst()takes from and returns to. - cap
- The pool size: the most particles that can ever exist.
- status message
- The
role="status"paragraph that tells every user the reservation worked. - --turn-name
- A custom property that holds
border-turnnormally andnonefor reduced motion.
Step by step
01Add confetti.js
Create src/js/components/confetti.js with the file below. It imports createTicker and the two motion helpers from earlier phases.
02Add the markup and the CSS
Put the #reserve-note paragraph after the Reserve button, and append the Phase 17 block to components.css.
03Wire the click
In main.js, import burst and call it in the Reserve handler, using the button's centre. Fill the message in the same handler.
04Try it
Click Reserve: paper rises and falls, and the border turns on hover. Switch on Reduced motion: the message appears and nothing flies. Hide the tab and come back: no jump.
Try it yourself
- 1
Fill the pool
In "The confetti after Phase 17" the console already shows it: three bursts of 60 make 150 alive and 30 not made. Change
PER_BURSTto 40 and the third burst fits: 120 alive, 0 not made. - 2
Run it as a reduced-motion user
Switch on Reduced motion in the timeline and the console says
0 of 150 in the airfor all three bursts andno canvas. The message still shows. - 3
Step through the burst
Wait two seconds for the load-time pieces to land, then click Reserve, pause, and step. The pieces rise, slow by drag, and fall. Each frame is one clear and up to 60 draws.
Code & diagrams
The Reserve button, message and confetti with the project's CSS, createTicker and confetti.js inlined (local imports do not run in the preview; two lines stand in for prefers-reduced.js). Click Reserve, then pause and step. The three bursts at the end run on fixed numbers and fill the pool at load, so a click in the first two seconds finds no free piece and adds none: wait for them to land, then click.
Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.
Console
Nothing logged yet.Expected console output
burst 1: 60 of 150 in the air, 0 not made
burst 2: 120 of 150 in the air, 0 not made
burst 3: 150 of 150 in the air, 30 not made
canvas hidden from screen readers: true
canvas ignores the pointer: trueYour project files
Your lumen-motion project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
// confetti.js: a burst of paper when someone reserves a Volt (Phase 17).
// One canvas, a fixed pool of particles, one shared loop. It does nothing when motion is off.
import { createTicker } from "../motion/loop.js";
import { motionAllowed, onMotionChange } from "../motion/prefers-reduced.js";
const CAPACITY = 150; // the most particles that can exist at once, however many bursts arrive
const PER_BURST = 60;
const COLOURS = ["#ff6b2c", "#2a9df4", "#ffc23d", "#1b1a22"]; // nothing paper-coloured: it would vanish on the page
const GRAVITY = 900; // pixels per second squared
const DRAG = 1.2; // share of speed lost per second
const ticker = createTicker({ maxDelta: 50 }); // a tab switch can never turn into one huge step
const pool = Array.from({ length: CAPACITY }, () => ({ alive: false, x: 0, y: 0, vx: 0, vy: 0, age: 0, life: 0, size: 0, turn: 0, spin: 0, colour: "" }));
let canvas = null, ctx = null, width = 0, height = 0, ratio = 1, dropped = 0;
function fit() {
ratio = window.devicePixelRatio || 1;
width = window.innerWidth; height = window.innerHeight;
canvas.width = Math.round(width * ratio); // this clears the canvas: the next frame draws everything again
canvas.height = Math.round(height * ratio);
}
function ensureCanvas() {
if (canvas) return;
canvas = document.createElement("canvas");
canvas.className = "confetti";
canvas.setAttribute("aria-hidden", "true"); // decoration: the message beside the button is what is announced
document.body.append(canvas);
ctx = canvas.getContext("2d");
fit();
window.addEventListener("resize", fit);
}
function frame(dtMs) {
const dt = dtMs / 1000;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
let live = 0;
for (const p of pool) {
if (!p.alive) continue;
p.age += dt;
if (p.age >= p.life || p.y > height + 20) { p.alive = false; continue; } // back to the pool
live++;
const k = Math.exp(-DRAG * dt);
p.vy = (p.vy + GRAVITY * dt) * k; p.vx *= k;
p.x += p.vx * dt; p.y += p.vy * dt; p.turn += p.spin * dt;
const c = Math.cos(p.turn) * ratio, s = Math.sin(p.turn) * ratio;
ctx.setTransform(c, s, -s, c, p.x * ratio, p.y * ratio);
ctx.globalAlpha = Math.min(1, (p.life - p.age) / 0.4); // fade over the last 0.4 s
ctx.fillStyle = p.colour;
ctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
}
return live > 0; // false removes us from the ticker: the loop sleeps until the next burst
}
function clear() {
for (const p of pool) p.alive = false;
if (ctx) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); }
}
// motion switched off while confetti is in the air: it goes at once
onMotionChange((allowed) => { if (!allowed) clear(); });
export function burst(x, y, random = Math.random) {
if (!motionAllowed() || document.hidden) return false; // nobody would see it
ensureCanvas();
let made = 0;
for (const p of pool) {
if (made === PER_BURST) break;
if (p.alive) continue;
const angle = -Math.PI / 2 + (random() - 0.5) * 2; // a fan, mostly upward
const speed = 300 + random() * 450;
p.alive = true; p.x = x; p.y = y; p.age = 0;
p.vx = Math.cos(angle) * speed; p.vy = Math.sin(angle) * speed;
p.life = 1.2 + random() * 0.8; p.size = 6 + random() * 6;
p.turn = random() * 6.28; p.spin = (random() - 0.5) * 16;
p.colour = COLOURS[Math.floor(random() * COLOURS.length)];
made++;
}
dropped += PER_BURST - made; // the pool was full: the extra paper is simply not made
if (made) ticker.add(frame); // a Set: adding the same function twice is safe
return made > 0;
}
export function confettiStats() {
return { active: pool.filter((p) => p.alive).length, capacity: CAPACITY, dropped };
}
Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget to reset the transform
In frame() delete the first ctx.setTransform(1, 0, 0, 1, 0, 0); line.
Myth vs fact
Myth
Skipping confetti for reduced motion makes the site worse for those users.
Fact
The message tells them everything the confetti did. What they lose is the movement, which is what they asked to lose.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The cap bounds work per frame, and the 150 objects live as long as the page, so a flood of clicks causes no garbage.
- ▸
ticker.add(frame)twice is safe because the ticker keeps aSet: the same function is stored once. In our test, two bursts gave the same alive counts with and without a guard. - ▸
The canvas covers the viewport, so a full-screen canvas at a pixel ratio of 3 is a big backing store. If it ever shows up in a profile, draw into a smaller canvas around the button.
Remember this
- 1
Why the site needs this now: Tomás wants the first reservation to feel like an event. Ravi's rule stands: with reduced motion nothing may fly. The answer is a small message, which is also what a screen reader announces.
- 2
What it adds.
confetti.jsmakes one canvas on first use,aria-hidden, fixed over the page withpointer-events: none. It holds a pool of 150 particles, shares the Phase 8createTicker()loop, and fits the canvas to the screen's pixel ratio. A burst makes 60 pieces. Measured on fixed numbers: 60 of 150 alive after burst 1, 120 after burst 2, and 150 after burst 3 with 30 not made. - 3
How it behaves.
burst()returnsfalseand makes nothing when motion is not allowed (Phase 7'smotionAllowed(), which covers the device setting and the site toggle) or when the tab is hidden. If motion is switched off while paper flies,onMotionChangeclears it at once. The frame function returnsfalsewhen no particle is alive, so the shared loop sleeps. Steps are clamped by the ticker'smaxDelta. The message paragraph (role="status") is filled whether or not paper flies, so no one depends on the picture. - 4
The button border.
@property --border-turnand aconic-gradientin theborder-boxlayer turn twice (1.5 s each) on hover or:focus-visible, never on a disabled button.--turn-namefollows the three reduced-motion rules of Phases 10 and 13. We measured 0deg at 0 ms, 180deg at 750 ms and 0deg again after the two turns, and with reduced motion 0deg with no animation. - 5
Bugs we found while testing: without resetting the transform each frame, the rotated pieces smeared into long streaks, because every piece sets its own transform (
frame()now resets it beforeclearRect). And a paper-coloured piece vanished on the page, so the colours are orange, blue, gold and ink. - 6
What the launch site can do now that it could not before: celebrate a reservation, within a hard cap, without harming reduced-motion users. What is still missing: motion tokens and tests (Phase 18), and the audit and final README (Phase 19). A real phone was not tested.
Explain it without notes
How does the build keep the confetti fair for reduced-motion users?
What stops a user from freezing the page by clicking Reserve many times?
Practice
Show how many pieces are alive in a data attribute on the message, updated each second.
Make the burst smaller on a small screen: 30 pieces below 480 px wide.
Done when you can
You can add a pooled, capped canvas effect that clears for motion changes
You can give the same news in a message that does not need motion
You can make a gradient border turn with
@propertyon hover and focusYou can test the effect with reduced motion and a hidden tab