Shots in this scene
Build: The Headlight Mascot
In one line
Add Mei's headlight to the launch page. It waves once, blinks on hover and focus, sleeps off screen, shows a still poster for reduced motion, and shows a plain shape if anything fails to load. The player downloads only when the mascot is near the screen.
Before you start
You will learn
- Copy
headlight.lottieintosrc/assets/mascot/and install the player - Add
src/js/components/mascot.js, the mascot button inindex.html, a CSS block and two lines inmain.js - Check the greeting, the blink, reduced motion, the pause off screen and the failure shape
You write code in
In your lumen-motion project (npm run dev) and in the editor on this page: add two files, copy one and change three
Files you touch
- src/assets/mascot/headlight.lottie
- src/js/components/mascot.js
- index.html
- src/styles/components.css
- src/js/main.js
Think of it like this
A doorman who bows once when you arrive, nods if you look at him, stands still if you have asked for quiet, and puts a painted sign on the door if he cannot come to work. The page still looks welcoming in every case.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- busy
- A flag in mascot.js that is true while a greeting or blink is playing, so a second one does not start on top.
- poster frame
- Frame 30 of the file, drawn when motion is off.
- ?url import
- A Vite suffix that gives a file's final address as a string instead of loading its contents.
- destroy function
- What
initMascotreturns: calling it disconnects the observer, removes listeners and frees the player.
Step by step
01Get the file and the player
Save headlight.lottie to src/assets/mascot/, then run npm install @lottiefiles/dotlottie-web@0.81.0 in the project.
02Add mascot.js
Create src/js/components/mascot.js with the contents below. It imports motionAllowed and onMotionChange from Phase 7's file.
03Add the button and the CSS
In index.html add the #mascot-button block after the reserve button, and append the Phase 16 block to components.css.
04Wire it
In main.js import initMascot and call it once with document.getElementById("mascot").
05Try every case
Reload and wait for the wave. Hover and Tab to it. Switch the footer's motion toggle (Phase 7) to Reduced. Scroll it away mid-greeting. Rename the file in mascot.js to see the plain lamp.
Try it yourself
- 1
Hover after the greeting
Wait for the greeting to end, then hover the lamp (or press Tab to it). The lamp blinks once: frames 45 to 60, about half a second.
- 2
Turn motion off mid-greeting
Press Simulate: motion off while the greeting plays. The lamp jumps to the still poster (frame 30) and stays. Press it again: nothing replays, because the greeting already happened.
- 3
Run it as a reduced-motion user
Switch on Reduced motion under the preview and run the example. The console says
mascot started, motion allowed: falseand the lamp is still, in the settled pose.
Code & diagrams
`mascot.js` inlined, with stand-ins for its three imports (the preview cannot import project files). **Simulate: motion off** flips the setting the way the footer toggle does; **Destroy** calls the function `initMascot` returned. Hover or Tab to the lamp after the greeting ends to see the blink.
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
mascot started, motion allowed: 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.
// mascot.js: the Lumen headlight (Phase 16). Mei's Lottie file, played by the dotLottie player on a canvas.
// ── Phase 16: the mascot ──
import { motionAllowed, onMotionChange } from "../motion/prefers-reduced.js";
// Vite's ?url suffix gives the file's final address as a string (it is not loaded here)
import fileUrl from "../../assets/mascot/headlight.lottie?url";
import wasmUrl from "@lottiefiles/dotlottie-web/dotlottie-player.wasm?url";
const POSTER_FRAME = 30; // the greeting has settled: lamp up, beam on
export function initMascot(canvas) {
if (!canvas) return () => {};
const host = canvas.closest("button") ?? canvas; // hover, focus and click go to the button around the canvas
let player = null;
let loaded = false, visible = false, greeted = false, busy = false, gone = false;
function failed() {
host.classList.add("mascot-failed"); // components.css shows a plain lamp shape instead
}
async function create() {
try {
// the player and its WASM are fetched only now, when the mascot is about to be seen
const { DotLottie } = await import("@lottiefiles/dotlottie-web");
if (gone) return;
DotLottie.setWasmUrl(wasmUrl); // our own copy, not a CDN
player = new DotLottie({ canvas, src: fileUrl, autoplay: false, loop: false, marker: "greet", renderConfig: { autoResize: true } });
player.addEventListener("load", () => { loaded = true; update(); });
player.addEventListener("loadError", failed); // a missing file, a bad file, or the WASM failing to load
player.addEventListener("complete", () => { busy = false; });
} catch {
failed(); // the import itself failed (offline, blocked)
}
}
// one place decides what the mascot does, from three facts: motion allowed? on screen? greeted yet?
function update() {
if (!loaded) return;
if (!motionAllowed()) { player.pause(); player.setFrame(POSTER_FRAME); busy = false; return; } // a still picture
if (!visible) { player.pause(); return; } // nobody sees it, so nothing runs
if (!greeted) { greeted = true; play("greet"); }
else if (busy) player.play(); // back on screen: carry on with what was interrupted
}
function play(marker) {
busy = true;
player.setMarker(marker);
player.play();
}
function blink() {
if (!loaded || busy || !visible || !motionAllowed()) return;
play("blink");
}
function wave() {
if (!loaded || !visible || !motionAllowed()) return;
play("greet");
}
host.addEventListener("pointerenter", blink);
host.addEventListener("focus", blink); // keyboard users get the same blink
host.addEventListener("click", wave);
const watcher = new IntersectionObserver((entries) => {
visible = entries[entries.length - 1].isIntersecting;
if (visible && !player) create(); // the first time it is near the screen
update();
}, { rootMargin: "100px" });
watcher.observe(canvas);
const stopListening = onMotionChange(update); // the visitor flips the motion setting while it plays
return function destroy() {
gone = true;
watcher.disconnect();
stopListening();
host.removeEventListener("pointerenter", blink);
host.removeEventListener("focus", blink);
host.removeEventListener("click", wave);
if (player) player.destroy(); // frees the WASM memory and the listeners
};
}
// ── end Phase 16 ──
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
Point at a file that is not there
In the example's JavaScript change const fileUrl = "/data/anim/headlight.lottie"; to "/data/anim/nope.lottie".
Break #2
Forget `busy`
In blink() delete busy || from the first line.
Myth vs fact
Myth
A decorative mascot needs no accessibility work.
Fact
The button needs a name, the motion needs an off switch and a still picture, and a failed download must not leave a hole. All are in 60 lines.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
?urlimports keep the WASM and the Lottie file under Vite's hashing and cache headers. A hard-coded/assets/...path would break when the build renames files. - ▸
On a hidden tab the browser stops
requestAnimationFrame. We did not test the mascot across a tab switch; if you ship it, test it, and add avisibilitychangecheck if the greeting jumps. - ▸
The mascot's click handler replays the greeting, which is why a button is right. If it did nothing, make it a plain
<div aria-hidden="true">instead and drop the focus blink.
Remember this
- 1
Why the site needs this now: Tomás wants the launch page to say hello. Mei drew the headlight and marked its stretches. Kofi has the numbers (Topic 16.1): a 1,150-byte file but a player of about 530 KB over the network, so the player must load late, and every failure must leave a friendly page.
- 2
What it adds.
headlight.lottieis copied from Hectal's/data/anim/(the course serves it there; save it fromhttps://hectal.in/data/anim/headlight.lottie) intosrc/assets/mascot/. Install the player withnpm install @lottiefiles/dotlottie-web@0.81.0.mascot.jsexportsinitMascot(canvas);index.htmlgets a button that holdscanvas#mascot(after the reserve button);components.cssa Phase 16 block;main.jsan import and one call. The binary file cannot be shown as text in the file list, so it is the one file of this build you copy by hand. We ranvite buildon the assembled project (Vite 8.3.5): the player became its own chunk of 61.35 kB (13.72 kB gzipped), the WASM was copied asdotlottie-player-<hash>.wasm(1,238.07 kB, 504.61 kB gzipped), and the 1,150-byte.lottiewas inlined into the main chunk, because Vite inlines assets under 4 kB. A headless Chromium smoke test of the built page found the canvas drawing, no console errors and no failure class. - 3
**How
mascot.jsdecides.** One function,update(), answers from three facts: is motion allowed (motionAllowed(), Phase 7), is the canvas near the screen, has it greeted yet. Motion off: pause and draw frame 30. Off screen: pause. First time on screen: playgreet. Back on screen: carry on if something was interrupted. Because there is one place that decides, the motion toggle, scrolling and loading cannot disagree. - 4
The interactions. Hover (
pointerenter) and focus play the 15-frameblink, but only when nothing else is playing (busy), the canvas is visible and motion is allowed. A click waves again. The canvas sits inside a<button>with anaria-label, so a keyboard user can reach it and gets the same blink; the canvas itself isaria-hidden. - 5
Lazy and safe. The
IntersectionObserver(100 px margin) callscreate()the first time the canvas is near the screen, which doesawait import("@lottiefiles/dotlottie-web"), sets our own WASM address (?urlimport, so Vite copies the file and gives its final path) and creates the player. AloadError, or a failingimport(), addsmascot-failed, and the CSS draws a plain lamp of the same size.initMascotreturns adestroy()that disconnects the observer, removes the listeners and callsplayer.destroy(). - 6
What we measured on the inlined version below: with motion on the greeting plays frames 0 to 45 and stops (frame 30 at the first probe after it started, 45 later); hovering after it ended played 45 to 60 (frame 53 at 300 ms); hovering during the greeting changed nothing (the segment stayed
[0,45]); with motion off it drew frame 30 and never played, also when the setting changed mid-greeting; a canvas moved off screen froze at frame 27.8 (and at 12.4 in a second run) and played on after it came back; a missing file showed the plain lamp. The 'Destroy' button calls the returned function. - 7
What the launch site can do now that it could not before: greet visitors with a designer's own animation, react to hover and keyboard focus, and stay calm and intact when motion is off or a download fails. What is still missing: the launch confetti when someone reserves a Volt (Phase 17), and the tokens file and test suite (Phase 18). We did not test the mascot on a real phone, with a screen reader, or on a hidden tab.
Explain it without notes
Why does mascot.js decide everything in one update() function?
Why is the canvas inside a button and aria-hidden?
What does the visitor see when the file, the player's WASM or the import() fails?
Practice
Change initMascot so that a second call with the same canvas returns the first call's destroy function instead of making a second player.
Add a data-mascot-state attribute on the host (loading, playing, poster, failed) for a test to read.
Done when you can
The mascot waves once, blinks on hover and focus, and does nothing while busy
With motion off or the site toggle on Reduced, it shows frame 30 and never plays
It pauses off screen and carries on, and the player loads only near the screen
A missing file, a failed WASM or a failed import all show the same plain lamp