Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 4.6Beginner ~34 min · shot 6 of 6

Build: The Toast Notification

In one line

Give the launch page its first feedback message: a toast that slides in when an accessory is added, waits, leaves, and removes itself. It handles two toasts in a row, a toast removed early, and reduced motion.

0/6 · 0%

Before you start

You will learn

  • Add the toast container, the toast styles and toast.js to your project
  • Call showToast() from main.js when an accessory is added
  • Handle two toasts, early dismissal and reduced motion

You write code in

In your lumen-motion project (npm run dev): start from the site as Phase 3 left it (Phase 1's loading bar, Phase 2's tokens, Phase 3's button feedback). Add src/js/components/toast.js, and add the marked Phase 4 blocks to index.html, src/styles/components.css and src/js/main.js

Files you touch

  • index.html
  • src/styles/components.css
  • src/js/main.js
  • src/js/components/toast.js

Think of it like this

The little card a waiter puts on your table to say "your order is in". It appears, you glance at it, and it is cleared away by itself. If two orders arrive, two cards sit side by side and the oldest goes first. A toast does that on a page.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

toast
A small message that appears briefly, then goes away by itself.
live region
An element (role="status", aria-live="polite") whose new content a screen reader reads out without moving focus.
dismiss
Start a toast's exit before its timer would.
stack
The toasts on the page, oldest first.

Step by step

01Add the container

In index.html, add <div id="toasts" role="status" aria-live="polite"></div> just before the script tag. It must exist when the page loads: screen readers only announce changes to a live region that was already there.

02Add the styles

Add the Phase 4 block to src/styles/components.css: the container, .toast, .toast.leaving, the keyframes, and the reduced-motion block with the fade keyframes.

03Add toast.js and call it

Create src/js/components/toast.js. In main.js, import showToast and call it in the Add handler with the card's name.

04Try the cases

Press Add Lock: it slides up, waits 2.6 s, fades down and is removed. Press Add two quickly: two toasts, the older one leaves first. Press Add, then click it: the toast lands, then leaves. Switch on Reduced motion: only fades.

Try it yourself

  1. 1

    Stack two

    Press Add two quickly, pause and step. Two toasts sit one above the other, each with its own toast-in track starting 150 ms apart. When their timers end, the older one leaves first.

  2. 2

    Dismiss it too early

    Press Add, then click it. The toast was dismissed at once, but the timeline shows toast-in still running to its end. Only then does toast-out start (measured: still running at 197 ms, toast-out running at 397 ms). It never flashes.

  3. 3

    Reduced motion

    Switch on Reduced motion and press Add Lock. The track is toast-fade-in (no transform), then toast-fade-out, and the toast is still removed.

Code & diagrams

The toast, with its edge cases

toast.js is inlined here. The counter under the buttons shows how many toasts are in the page. The last two lines of the script create two short toasts and count them.

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

right away: 2
after both have left: 0
The launch page, with toasts

The page as it stands after this phase: index.html's body, the tokens and both stylesheets (Phases 1 to 4), toast.js and main.js inlined (local imports do not run in the preview). Phase 1's loading bar is left out here: it needs loop.js and a real page load. Press two Add buttons.

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

toast container: status

Your project files

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

index.htmlhtml

The whole file after this topic. If yours differs, you can copy this one and carry on.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lumen Volt · Lumen Bikes</title>
    <!-- Phase 2: motion tokens (load first, so later files can use them) -->
    <link rel="stylesheet" href="/src/styles/tokens.css" />
    <!-- end Phase 2 -->
    <link rel="stylesheet" href="/src/styles/base.css" />
    <link rel="stylesheet" href="/src/styles/components.css" />
  </head>
  <body>
    <header class="site-header">
      <a class="logo" id="logo" href="/">lumen</a>
      <nav aria-label="Main"><a href="#accessories">Accessories</a> <a href="#gallery">Gallery</a> <a href="#specs">Specs</a></nav>
      <button id="cart-button" class="cart-button" aria-label="Cart, 0 items">Cart <span id="cart-count">0</span></button>
    </header>
    <main>
      <section id="hero" class="hero">
        <div>
          <h1>The Lumen Volt</h1>
          <p class="hero-copy">Our first e-bike. 80 km on one charge, built for Ardenvale's hills.</p>
          <p class="price">€2,490</p>
          <button id="reserve" class="button primary">Reserve yours</button>
          <!-- Phase 1: loading the Volt -->
          <div class="loading-wrap">
            <div class="loading" id="volt-loading" role="progressbar" aria-label="Loading the Volt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="loading-fill"></div></div>
            <p class="loading-label" id="volt-loading-label">Loading the Volt…</p>
          </div>
          <!-- end Phase 1 -->
        </div>
        <svg id="volt-hero" class="volt" viewBox="0 0 240 140" role="img" aria-label="The Lumen Volt e-bike">
          <g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="52" cy="98" r="34" />
            <circle cx="188" cy="98" r="34" />
            <path d="M52 98 L96 98 L132 50 L84 50 Z M96 98 L72 38 M132 50 L188 98 M126 34 L140 34 M132 50 L130 34" />
            <path d="M64 38 L82 38" />
          </g>
          <rect x="98" y="58" width="26" height="14" rx="4" fill="#2a9df4" />
        </svg>
      </section>
      <section id="accessories">
        <h2>Accessories</h2>
        <div class="grid">
          <article class="card"><h3>Lock</h3><p>€39</p><button class="add" data-product="lock">Add</button></article>
          <article class="card"><h3>Lights</h3><p>€29</p><button class="add" data-product="lights">Add</button></article>
          <article class="card"><h3>Rack</h3><p>€45</p><button class="add" data-product="rack">Add</button></article>
          <article class="card"><h3>Bell</h3><p>€9</p><button class="add" data-product="bell">Add</button></article>
          <article class="card"><h3>Basket</h3><p>€35</p><button class="add" data-product="basket">Add</button></article>
          <article class="card"><h3>Helmet</h3><p>€59</p><button class="add" data-product="helmet">Add</button></article>
        </div>
      </section>
      <section id="gallery">
        <h2>Gallery</h2>
        <div class="filters" role="group" aria-label="Show colour">
          <button class="filter" data-color="all" aria-pressed="true">All</button>
          <button class="filter" data-color="orange" aria-pressed="false">Orange</button>
          <button class="filter" data-color="blue" aria-pressed="false">Blue</button>
          <button class="filter" data-color="ink" aria-pressed="false">Ink</button>
        </div>
        <div class="photos">
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Volt in orange, side</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Volt in blue, side</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Volt in ink, side</figcaption></figure>
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Orange, at the harbour</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Blue, in the old town</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Ink, at night</figcaption></figure>
        </div>
      </section>
      <section id="specs">
        <h2>Specs</h2>
        <details class="spec"><summary>Battery</summary><p>500 Wh, removable, 80 km range, full in 4.5 hours.</p></details>
        <details class="spec"><summary>Motor</summary><p>250 W rear-hub motor, assists up to 25 km/h.</p></details>
        <details class="spec"><summary>Frame</summary><p>Aluminium, three sizes, 21 kg with the battery.</p></details>
      </section>
    </main>
    <footer class="site-footer"><p>Lumen Bikes · Ardenvale</p></footer>
    <!-- Phase 4: where toasts appear (a live region: screen readers read what is added to it) -->
    <div id="toasts" role="status" aria-live="polite"></div>
    <!-- end Phase 4 -->
    <script type="module" src="/src/js/main.js"></script>
  </body>
</html>

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

Remove it on any animationend

In toast.js delete && toast.classList.contains("leaving") from the animationend listener.

terminal
$ Press Add Lock and count the toasts
── what you'll see ──
The toast disappears 220 ms after it appears: 1 toast at 30 ms, 0 at 280 ms.
Measured. The enter animation's end is treated as the exit's.

Break #2

Animations switched off, no fallback

Add .toast, .toast.leaving { animation: none; } to the CSS and delete the line if (toast.getAnimations().length === 0) remove(); from toast.js.

terminal
$ Wait 3.4 seconds
── what you'll see ──
toasts in the page: 3 (and still 3). Nothing removes them.
Measured: the two toasts the page makes at load plus one more, all still there at 3.4 s.

Break #3

Dismiss during the slide-in, without the guard

Delete the if (toast.getAnimations().some(...)) { ...; return; } block in dismiss().

terminal
$ Dismiss at 60 ms and read the opacity before and after one frame
── what you'll see ──
before: opacity 0.80, 3.2 px low. After one frame: opacity 0.99, 0.08 px.
Measured: the exit's missing `from` is the toast's own full-opacity style.

Myth vs fact

Myth

A toast should have role="alert" so it is noticed.

Fact

alert interrupts whatever the screen reader is saying. A confirmation of something the user just did should be polite: role="status".

Myth

If the exit animation is short, I can use a timer to remove the toast.

Fact

Timers drift from the animation (paused tabs, slowed clocks, changed durations). animationend is tied to the animation itself.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The container exists from page load. Setting role="status" on each new toast, or creating the container with the first toast, often makes screen readers say nothing.

  • ▸

    A toast that disappears on a timer is a problem for people who read slowly or use magnification. Keep it for confirmations that are also visible elsewhere (the cart count). Anything needing an action, or an error, belongs in the page, not in a toast.

  • ▸

    When the oldest toast leaves from a stack, the ones below do not move smoothly: they jump to their new place. Phase 10 (FLIP) animates that.

Remember this

  1. 1

    The problem this solves (Lena's problem): the cart count changes in the corner, far from the button just pressed, in one frame. People press Add again. A message near the bottom of the screen that comes in with a short slide, says what happened and leaves is feedback they notice and cannot miss. It also tells screen-reader users, through a live region.

  2. 2

    What it adds, and how it plugs in. index.html gets a container div#toasts with role="status". components.css gets the toast styles and two keyframes, toast-in (220 ms, 16 px up) and toast-out (120 ms). toast.js exports showToast(message, { duration }). main.js calls it when an accessory is added. The Phase 2 tokens are used with fallbacks, so the file works before and after them.

  3. 3

    How it works. showToast creates a div.toast, adds it to the container, and starts a timer. When the timer ends the class leaving is added, which swaps in the exit animation. When that exit animation ends, animationend removes the toast from the page. The enter animation also ends, so the listener checks the class. Three cases are handled: a fourth toast makes the oldest leave, a toast dismissed while still sliding in waits for the slide to finish (otherwise it snaps back to full opacity for one frame), and if animations are switched off the toast is removed at once instead of waiting for an event that never comes.

  4. 4

    Reduced motion. A @media (prefers-reduced-motion: reduce) block swaps the animation names for fades: the toast still appears and leaves, without any movement. The live region still announces the text.

  5. 5

    What the launch site can do now that it could not before: pressing Add confirms next to your finger, the confirmation is announced to screen readers, and rapid adds stack calmly. What is still missing: the cart count itself still changes silently (Phase 8 adds the bump), the stack does not slide when a toast leaves (Phase 10, FLIP), and the site-wide motion switch comes in Phase 7.

Explain it without notes

01

Why must the toast container be in the HTML from the start?

02

Why does the animationend listener check for the leaving class?

03

What two cases does dismiss() guard against?

Practice

01

Add a type option ("success" or "error") to showToast that sets a class. Should an error toast use the same 2.6 s timer? Why or why not?

02

Make a toast stay while the pointer is over it, and leave 1 s after the pointer goes away.

Done when you can

  • Your project shows a toast when an accessory is added

  • Two quick adds show two toasts, and each leaves by itself

  • A toast dismissed early waits for its entrance, then leaves

  • With reduced motion the toast fades and does not move