Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 17.6Advanced ~25 min · shot 6 of 6

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.

0/6 · 0%

Before you start

You will learn

  • Add src/js/components/confetti.js, exporting burst(x, y)
  • Add the Phase 17 block to components.css and the message paragraph to index.html
  • Call burst from the Reserve handler in main.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-turn normally and none for 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. 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_BURST to 40 and the third burst fits: 120 alive, 0 not made.

  2. 2

    Run it as a reduced-motion user

    Switch on Reduced motion in the timeline and the console says 0 of 150 in the air for all three bursts and no canvas. The message still shows.

  3. 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 confetti after Phase 17

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.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

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: true

Your project files

Your lumen-motion project after this topic. Click a file to see it in full.

src/js/components/confetti.jsjs

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.

terminal
$ Click Reserve and look at the frame at 800 ms
── what you'll see ──
Stacked, stair-stepped smears of earlier frames behind some pieces (screenshot at 800 ms): the canvas is only partly cleared, because `clearRect` runs inside the last piece's rotated, shifted space. Pieces away from that area look normal.

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 a Set: 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. 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. 2

    What it adds. confetti.js makes one canvas on first use, aria-hidden, fixed over the page with pointer-events: none. It holds a pool of 150 particles, shares the Phase 8 createTicker() 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. 3

    How it behaves. burst() returns false and makes nothing when motion is not allowed (Phase 7's motionAllowed(), which covers the device setting and the site toggle) or when the tab is hidden. If motion is switched off while paper flies, onMotionChange clears it at once. The frame function returns false when no particle is alive, so the shared loop sleeps. Steps are clamped by the ticker's maxDelta. The message paragraph (role="status") is filled whether or not paper flies, so no one depends on the picture.

  4. 4

    The button border. @property --border-turn and a conic-gradient in the border-box layer turn twice (1.5 s each) on hover or :focus-visible, never on a disabled button. --turn-name follows 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. 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 before clearRect). And a paper-coloured piece vanished on the page, so the colours are orange, blue, gold and ink.

  6. 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

01

How does the build keep the confetti fair for reduced-motion users?

02

What stops a user from freezing the page by clicking Reserve many times?

Practice

01

Show how many pieces are alive in a data attribute on the message, updated each second.

02

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 @property on hover and focus

  • You can test the effect with reduced motion and a hidden tab