Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 8.6Intermediate ~56 min · shot 6 of 6

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.

0/6 · 0%

Before you start

You will learn

  • Add cart.js with addToCart(productEl) and call it from main.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. 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 - 120 to - 40 or - 400 shows no change: the cart sits near the top, so the 16 limit decides the arc. Without Math.max(…, 16) the dot leaves the top of the screen (y -35).

  2. 2

    Slow the flight and press twice

    Set FLIGHT_MS to 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

The cart, with its edge cases

cart.js is inlined, with a stand-in for Phase 7's motionAllowed(). The script clicks both Add buttons at once.

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: dots in flight 2, count on screen 0, aria-label Cart, 2 items
after the flights: dots 0, count on screen 2
The launch page, with the cart

The page after this phase, with the project's files inlined (local imports do not run in the preview). Press two Add buttons quickly.

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

cart label: Cart, 0 items

Your project files

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

src/js/components/cart.jsjs

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.

terminal
$ Run it and look at the page
── what you'll see ──
Both dots jump to the top-left corner of the page and stay there. A console line added after the flights reads: dots 2, count on screen 0.
From a real run: the dots were never removed and the count never landed.

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

    What it adds, and how it plugs in. cart.js exports addToCart(productEl). main.js calls it in the Add handler instead of changing the count itself, and keeps Phase 4's showToast. components.css gets the .fly-dot style. loop.js gets the Topic 8.1 ticker, ready for Phase 9's spring. cart.js asks motionAllowed() (Phase 7). index.html does not change, and main.js and components.css keep every earlier marked block: this phase only adds Phase 8 blocks.

  3. 3

    How it works. addToCart first counts the item and sets the cart button's aria-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. One animate() call plays them. The finish event (and cancel, so a cancelled flight never loses the count) removes the dot and lands the new number with a 260 ms scale bump.

  4. 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: none it 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, so showCount() only ever raises the number on screen: it never counts backwards.

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

01

Why does addToCart set the aria-label immediately but change the visible number only when the dot lands?

02

How does the code make sure no dot is ever left on the page?

Practice

01

Make the dot fade out in its last 15% of the path, without a second animation.

02

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