Shots in this scene
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.
Before you start
You will learn
- Add the toast container, the toast styles and
toast.jsto your project - Call
showToast()frommain.jswhen 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
Stack two
Press Add two quickly, pause and step. Two toasts sit one above the other, each with its own
toast-intrack starting 150 ms apart. When their timers end, the older one leaves first. - 2
Dismiss it too early
Press Add, then click it. The toast was dismissed at once, but the timeline shows
toast-instill running to its end. Only then doestoast-outstart (measured: still running at 197 ms,toast-outrunning at 397 ms). It never flashes. - 3
Reduced motion
Switch on Reduced motion and press Add Lock. The track is
toast-fade-in(no transform), thentoast-fade-out, and the toast is still removed.
Code & diagrams
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.
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
right away: 2
after both have left: 0The 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.
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
toast container: statusYour 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.
<!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.
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.
Break #3
Dismiss during the slide-in, without the guard
Delete the if (toast.getAnimations().some(...)) { ...; return; } block in dismiss().
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
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
What it adds, and how it plugs in.
index.htmlgets a containerdiv#toastswithrole="status".components.cssgets the toast styles and two keyframes,toast-in(220 ms, 16 px up) andtoast-out(120 ms).toast.jsexportsshowToast(message, { duration }).main.jscalls it when an accessory is added. The Phase 2 tokens are used with fallbacks, so the file works before and after them. - 3
How it works.
showToastcreates adiv.toast, adds it to the container, and starts a timer. When the timer ends the classleavingis added, which swaps in the exit animation. When that exit animation ends,animationendremoves 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
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
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
Why must the toast container be in the HTML from the start?
Why does the animationend listener check for the leaving class?
What two cases does dismiss() guard against?
Practice
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?
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