Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 16.6Advanced ~22 min · shot 6 of 6

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.

0/6 · 0%

Before you start

You will learn

  • Copy headlight.lottie into src/assets/mascot/ and install the player
  • Add src/js/components/mascot.js, the mascot button in index.html, a CSS block and two lines in main.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 initMascot returns: 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. 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. 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. 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: false and the lamp is still, in the settled pose.

Code & diagrams

The mascot after Phase 16

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

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

mascot started, motion allowed: true

Your project files

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

src/js/components/mascot.jsjs

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

terminal
$ Press Run
── what you'll see ──
The console shows the library's `[error] Failed to load animation data from URL: /data/anim/nope.lottie … 404: Not Found`. The canvas disappears and an orange and black plain lamp of the same size takes its place.

Break #2

Forget `busy`

In blink() delete busy || from the first line.

terminal
$ Hover the lamp during the greeting
── what you'll see ──
The greeting is cut off: the segment switches to `[45,60]` and the lamp jumps to the blink.

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.

  • ▸

    ?url imports 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 a visibilitychange check 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. 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. 2

    What it adds. headlight.lottie is copied from Hectal's /data/anim/ (the course serves it there; save it from https://hectal.in/data/anim/headlight.lottie) into src/assets/mascot/. Install the player with npm install @lottiefiles/dotlottie-web@0.81.0. mascot.js exports initMascot(canvas); index.html gets a button that holds canvas#mascot (after the reserve button); components.css a Phase 16 block; main.js an 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 ran vite build on 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 as dotlottie-player-<hash>.wasm (1,238.07 kB, 504.61 kB gzipped), and the 1,150-byte .lottie was 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. 3

    **How mascot.js decides.** 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: play greet. 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. 4

    The interactions. Hover (pointerenter) and focus play the 15-frame blink, 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 an aria-label, so a keyboard user can reach it and gets the same blink; the canvas itself is aria-hidden.

  5. 5

    Lazy and safe. The IntersectionObserver (100 px margin) calls create() the first time the canvas is near the screen, which does await import("@lottiefiles/dotlottie-web"), sets our own WASM address (?url import, so Vite copies the file and gives its final path) and creates the player. A loadError, or a failing import(), adds mascot-failed, and the CSS draws a plain lamp of the same size. initMascot returns a destroy() that disconnects the observer, removes the listeners and calls player.destroy().

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

01

Why does mascot.js decide everything in one update() function?

02

Why is the canvas inside a button and aria-hidden?

03

What does the visitor see when the file, the player's WASM or the import() fails?

Practice

01

Change initMascot so that a second call with the same canvas returns the first call's destroy function instead of making a second player.

02

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