Shots in this scene
Build: The Cart Bump and the Fly-to-Cart Animation
In one line
Give the Add buttons their payoff: a dot flies from the product to the cart on an arc, the cart count bumps when it lands, two quick clicks both work, and reduced motion gets a fade instead of a flight.
Before you start
You will learn
- Add
cart.jswithaddToCart(productEl)and call it frommain.js - Fly a dot on an arc with one WAAPI animation, and remove it when it lands
- Handle two quick clicks, a hidden cart button and reduced motion
You write code in
In your lumen-motion project (npm run dev): add src/js/components/cart.js, and change src/js/motion/loop.js, src/js/main.js and src/styles/components.css
Files you touch
- src/js/components/cart.js
- src/js/motion/loop.js
- src/js/main.js
- src/styles/components.css
Think of it like this
In a shop, the assistant puts your item into the bag and you watch it go in. If it vanished from the counter, you would ask where it went. The dot flying to the cart is the item going into the bag.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- fly-dot
- A small round div, fixed in the viewport, that carries the flight and is removed when it lands.
- control point
- The point that bends a curve: the dot's path is pulled toward it but does not pass through it.
- landing
- The moment the flight ends: the dot is removed and the visible count changes and bumps.
- total
- The real number of items in cart.js. The number on screen catches up when a dot lands.
Step by step
01Add the dot style and cart.js
Add the Phase 8 block to components.css (.fly-dot), and create src/js/components/cart.js with addToCart. It imports motionAllowed from the Phase 7 file.
02Change main.js
Import addToCart and call it in the Add handler with the card. Keep the showToast line below it. main.js no longer touches the count.
03Press Add twice, fast
In "The cart, with its edge cases" two adds run in the same instant. The first line says dots in flight 2, count on screen 0, aria-label Cart, 2 items: the label is already right, the number waits for the dots. About a second later: dots 0, count on screen 2.
04Try the other cases
Switch on Reduced motion and press Replay: dots in flight 0 and the number is already 2. Hide the cart button, then Add: no dot flies, and the number fades.
Try it yourself
- 1
Change the arc
In
cart.js, change, 16)to, 60)and add an accessory. The control point is lower, so the dot flies a flatter curve: in a real run, half way along (280 ms), the dot was at y 46 instead of y 25 (the straight line is at about 41). Changing- 120to- 40or- 400shows no change: the cart sits near the top, so the16limit decides the arc. WithoutMath.max(…, 16)the dot leaves the top of the screen (y -35). - 2
Slow the flight and press twice
Set
FLIGHT_MSto 2000, switch the timeline to 0.25× and press Add twice, a second apart. Two dots fly at once, and the number rises 1, then 2, as each lands.
Code & diagrams
cart.js is inlined, with a stand-in for Phase 7's motionAllowed(). The script clicks both Add buttons at once.
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: dots in flight 2, count on screen 0, aria-label Cart, 2 items
after the flights: dots 0, count on screen 2The page after this phase, with the project's files inlined (local imports do not run in the preview). Press two Add buttons quickly.
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
cart label: Cart, 0 itemsYour 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.
// cart.js: the cart count bumps, and a dot flies from the product to the cart button (Phase 8).
import { motionAllowed } from "../motion/prefers-reduced.js"; // Phase 7: the system setting plus the site toggle
const FLIGHT_MS = 560;
const BUMP_MS = 260;
const EASE = "cubic-bezier(0.65, 0, 0.35, 1)"; // the --ease-in-out token
let total = 0; // the real number of items. The number on screen catches up when the dot lands.
// Flights can land out of order, or after a no-flight add already showed a higher number: the screen never counts backwards.
function showCount(count, n) {
count.textContent = Math.max(Number(count.textContent) || 0, n);
}
export function addToCart(productEl) {
const count = document.getElementById("cart-count");
const button = document.getElementById("cart-button");
const n = ++total;
button.setAttribute("aria-label", `Cart, ${n} item${n === 1 ? "" : "s"}`); // screen readers hear it at once
const to = button.getBoundingClientRect();
if (!motionAllowed() || to.width === 0) {
// reduced motion, or the cart button is hidden (display: none measures 0 x 0): no flight, a fade instead
showCount(count, n);
count.animate({ opacity: [0.2, 1] }, { duration: 200 });
return n;
}
fly(productEl.getBoundingClientRect(), to, () => {
showCount(count, n);
count.animate({ transform: ["scale(1)", "scale(1.5)", "scale(1)"] }, { duration: BUMP_MS, easing: "ease-out" });
});
return n;
}
// A dot travels along a curve from one rectangle to another, then removes itself and calls land().
function fly(from, to, land) {
const dot = document.createElement("div");
dot.className = "fly-dot";
dot.setAttribute("aria-hidden", "true");
document.body.append(dot);
const sx = from.left + from.width / 2;
const sy = from.top + from.height / 2;
const ex = to.left + to.width / 2;
const ey = to.top + to.height / 2;
const cx = (sx + ex) / 2; // the control point sits above both ends: that makes the arc
const cy = Math.max(Math.min(sy, ey) - 120, 16); // ...but not above the top of the screen
const frames = Array.from({ length: 13 }, (_, i) => {
const t = i / 12;
const u = 1 - t;
const x = u * u * sx + 2 * u * t * cx + t * t * ex; // a quadratic curve, sampled
const y = u * u * sy + 2 * u * t * cy + t * t * ey;
return { transform: `translate(${x}px, ${y}px) scale(${1 - 0.55 * t})` };
});
const flight = dot.animate(frames, { duration: FLIGHT_MS, easing: EASE });
let landed = false;
const end = () => {
if (landed) return; // finish and cancel can both arrive: land once
landed = true;
dot.remove();
land();
};
flight.addEventListener("finish", end);
flight.addEventListener("cancel", end); // a cancelled flight must not lose the count
}
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 the dot only on finish, and cancel the flight
In "The cart, with its edge cases", delete the line flight.addEventListener("cancel", end); and add setTimeout(() => document.getAnimations().forEach((a) => a.cancel()), 200); at the end of the script.
Myth vs fact
Myth
A fly-to-cart animation needs a library.
Fact
One animate() call with 13 sampled keyframes does it. A library helps with sequences and physics.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The dot's start and end are measured once, at click time. If the page scrolls during the 560 ms flight, a cart button that is not sticky moves away from the target. A sticky header avoids it, or measure again on each frame with the ticker.
- ▸
The flight is a transform-only animation, so the timeline marks it green (composite). The count bump is also transform, so the whole effect costs no layout.
Remember this
- 1
The problem this solves (Lena's problem, closed): the toast says something was added, but the cart itself stays silent, and the count changes in one frame. Now the eye follows a dot from the product to the cart, and the count bumps when the dot arrives.
- 2
What it adds, and how it plugs in.
cart.jsexportsaddToCart(productEl).main.jscalls it in the Add handler instead of changing the count itself, and keeps Phase 4'sshowToast.components.cssgets the.fly-dotstyle.loop.jsgets the Topic 8.1 ticker, ready for Phase 9's spring.cart.jsasksmotionAllowed()(Phase 7).index.htmldoes not change, andmain.jsandcomponents.csskeep every earlier marked block: this phase only addsPhase 8blocks. - 3
How it works.
addToCartfirst counts the item and sets the cart button'saria-label, so screen readers hear it at once. Then it measures the product card and the cart button, and builds 13 keyframes along a curve between them: a quadratic curve whose control point sits above both ends gives the arc, and the dot shrinks as it goes. Oneanimate()call plays them. Thefinishevent (andcancel, so a cancelled flight never loses the count) removes the dot and lands the new number with a 260 ms scale bump. - 4
The cases. Two quick clicks make two dots and two landings, showing 1 then 2, with no dot left. If the cart button is
display: noneit measures 0 × 0, so there is no flight: the number just fades. With reduced motion the same fade replaces the flight (the number fades in over 200 ms), and nothing flies. A fade-in can land while an older flight is still in the air, soshowCount()only ever raises the number on screen: it never counts backwards. - 5
What the launch site can do now that it could not before: pressing Add sends a dot to the cart and bumps the count when it lands, in a way that survives ten fast clicks. What is still missing: the cart button does not open anything yet (the drawer, with a spring, is Phase 9), the flight is a plain
ease-in-out(springs are Phase 9), and the confetti on Reserve is Phase 17.
Explain it without notes
Why does addToCart set the aria-label immediately but change the visible number only when the dot lands?
How does the code make sure no dot is ever left on the page?
Practice
Make the dot fade out in its last 15% of the path, without a second animation.
Add an optional onLand callback to addToCart that is called when the number lands, and also when reduced motion skips the flight.
Done when you can
Your project has cart.js and main.js calls addToCart for every Add button
A dot flies on an arc, is removed, and the count bumps when it lands
Two quick clicks leave no dot behind and the count is right
Reduced motion and a hidden cart button get a fade, not a flight