How to Build an Add to Cart Animation and Shopping Cart with HTML, CSS and JavaScript

How to Build an Add to Cart Animation and Shopping Cart with HTML, CSS and JavaScript

Clicking “Add to cart” and seeing only a number change from 2 to 3 feels flat. In this tutorial, the product itself leaps off its card, flies in a curve to the cart icon, shrinks and spins on the way, and the cart wiggles when it lands. People see exactly where their item went.

Then we build the rest of a small shop around it:

A shop page with six product cards; a headphones emoji is flying through the air towards the cart icon, and its button reads Added
Mid-flight: the headphones arc towards the cart in the top corner.

Try the live demo Add a few products, then open the cart.

It's built with plain HTML, CSS and JavaScript, with no libraries and no image files. The products are emoji on coloured tiles, so you can swap in real photos later.

Project setup

shopping-cart/
├── index.html   ← header, empty product grid, cart drawer
├── style.css    ← cards, flight layers, drawer, truck
└── script.js    ← products, flight, cart state, drawer

The code blocks below follow each file from top to bottom. Copy them in order and you'll have the complete files.

Step 1: The HTML

1.1 Head and header

index.html: head and header
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Animated Shopping Cart – Add to Cart Animation</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
index.html: header and product grid
  <!-- ---------- HEADER ---------- -->
  <header class="top">
    <a href="#" class="brand">nook<span>.</span></a>
    <button type="button" class="cart-btn" aria-expanded="false" aria-controls="drawer" aria-label="Open cart, 0 items">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 4h2l2.4 11.2a2 2 0 0 0 2 1.6h7.7a2 2 0 0 0 2-1.5L21 8H6.2"/><circle cx="10" cy="20.5" r="1.3"/><circle cx="17" cy="20.5" r="1.3"/></svg>
      <span class="badge" aria-hidden="true">0</span>
    </button>
  </header>
  <!-- ---------- END HEADER ---------- -->

  <main class="shop">
    <h1>Summer drop <span aria-hidden="true">☀️</span></h1>
    <p class="sub">Tap <b>Add to cart</b> and watch it fly. Free shipping over $150.</p>
    <!-- script.js fills this grid from the PRODUCTS list -->
    <ul class="grid"></ul>
  </main>

The cart button has aria-expanded, which tells screen readers whether the drawer is open, and an aria-label that we'll keep updated with the item count, like “Open cart, 3 items”. The badge itself is hidden from screen readers, because the label already says the number.

The product list is an empty <ul>. JavaScript fills it from a list of products, the same way a real shop would render products from its database.

1.2 The cart drawer

index.html: cart drawer
  <!-- ---------- CART DRAWER ---------- -->
  <div class="backdrop" hidden></div>
  <aside class="drawer" id="drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title" inert>
    <div class="drawer-head">
      <h2 id="cart-title">Your cart</h2>
      <button type="button" class="close" aria-label="Close cart">✕</button>
    </div>

    <div class="ship">
      <p class="ship-text"></p>
      <div class="ship-bar"><span></span></div>
    </div>

    <ul class="items"></ul>

    <div class="empty">
      <span class="empty-bag" aria-hidden="true">🛍️</span>
      <p>Your cart is empty.</p>
      <button type="button" class="keep">Keep shopping</button>
    </div>

    <div class="drawer-foot">
      <div class="total"><span>Subtotal</span><b class="subtotal">$0.00</b></div>
      <button type="button" class="checkout">
        <span class="label">Checkout</span>
        <span class="truck" aria-hidden="true">🚚</span>
      </button>
    </div>
  </aside>
  <!-- ---------- END CART DRAWER ---------- -->

  <p class="sr-only" role="status" aria-live="polite"></p>

  <script src="script.js"></script>
</body>
</html>

The drawer is a dialog: role="dialog", aria-modal="true" and a label taken from its heading. It starts inert, so nobody can tab into it while it's off screen. Inside are the shipping bar, the item list, an empty state, and the footer with the total and checkout button.

The last paragraph is visually hidden with .sr-only but read aloud by screen readers. We'll write messages like “Trail Pack added to cart.” into it.

Step 2: The CSS

2.1 Base and header

style.css: base
/* ---------- BASE ---------- */
* { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --bg: #fff8f1;
  --ink: #1f1a17;
  --muted: #7a6f66;
  --card: #ffffff;
  --accent: #ff6a3d;
  --accent-dark: #e24e22;
  --ok: #16a34a;
  --radius: 20px;
}

body {
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  overflow-x: hidden;
}
body.locked { overflow: hidden; }   /* stop the page scrolling behind the open cart */

button { font: inherit; color: inherit; cursor: pointer; }
button:focus-visible, a:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }

.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---------- END BASE ---------- */
style.css: header and badge
/* ---------- HEADER ---------- */
.top {
  position: sticky;
  top: 0;
  z-index: 5;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 14px clamp(16px, 4vw, 48px);
  background: rgba(255, 248, 241, .85);
  backdrop-filter: blur(10px);
}
.brand { font-size: 26px; font-weight: 800; color: var(--ink); text-decoration: none; letter-spacing: -1px; }
.brand span { color: var(--accent); }

.cart-btn {
  position: relative;
  width: 48px;
  height: 48px;
  border: 0;
  border-radius: 50%;
  background: var(--ink);
  display: grid;
  place-items: center;
}
.cart-btn svg { width: 24px; height: 24px; fill: none; stroke: #fff; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.cart-btn.wiggle { animation: wiggle .5s ease; }

.badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 11px;
  background: var(--accent);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  line-height: 22px;
  text-align: center;
  transform: scale(0);
  transition: transform .3s cubic-bezier(.3, 1.6, .5, 1);
}
.badge.has { transform: scale(1); }
.badge.bump { animation: bump .4s ease; }
/* ---------- END HEADER ---------- */

The header is position: sticky, so the cart icon stays visible while you scroll. That matters here: the product always needs somewhere to fly to.

The badge starts at scale(0) and grows to full size once there's something in the cart. Its curve, cubic-bezier(.3, 1.6, .5, 1), has a value above 1, which makes it overshoot and settle, a little bounce. .wiggle rocks the cart left and right, and .bump briefly enlarges the number.

2.2 Product cards

style.css: product grid
/* ---------- PRODUCT GRID ---------- */
.shop { max-width: 1100px; margin: 0 auto; padding: 24px clamp(16px, 4vw, 48px) 60px; }
.shop h1 { font-size: clamp(30px, 5vw, 46px); letter-spacing: -1px; }
.sub { color: var(--muted); margin: 6px 0 28px; }

.grid {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 20px;
}

.product {
  background: var(--card);
  border-radius: var(--radius);
  padding: 14px;
  box-shadow: 0 2px 0 rgba(0, 0, 0, .04), 0 12px 30px rgba(80, 40, 10, .07);
  transition: transform .3s cubic-bezier(.2, .9, .3, 1.3), box-shadow .3s;
  animation: enter .6s cubic-bezier(.2, .9, .3, 1.2) both;
  animation-delay: calc(var(--i) * 80ms);   /* cards arrive one after another */
}
.product:hover { transform: translateY(-6px); box-shadow: 0 20px 40px rgba(80, 40, 10, .14); }

.thumb {
  height: 170px;
  border-radius: 14px;
  display: grid;
  place-items: center;
  background: var(--tint);
  overflow: hidden;
}
.emoji {
  font-size: 84px;
  line-height: 1;
  transition: transform .4s cubic-bezier(.2, .9, .3, 1.4);
  filter: drop-shadow(0 12px 10px rgba(0, 0, 0, .15));
}
.product:hover .emoji { transform: scale(1.12) rotate(-8deg); }

.info { display: flex; justify-content: space-between; align-items: baseline; margin: 14px 4px 12px; gap: 8px; }
.info h3 { font-size: 16px; }
.price { font-weight: 800; }

.add {
  position: relative;
  width: 100%;
  padding: 12px;
  border: 0;
  border-radius: 12px;
  background: var(--ink);
  color: #fff;
  font-weight: 700;
  overflow: hidden;
  transition: background .3s, transform .15s;
}
.add:hover { background: var(--accent); }
.add:active { transform: scale(.97); }
.add.added { background: var(--ok); }
/* ---------- END PRODUCT GRID ---------- */

repeat(auto-fill, minmax(220px, 1fr)) makes as many columns as fit, each at least 220px wide: four on a laptop, two on a phone, with no media queries.

Each card gets its number in --i and its tile colour in --tint. animation-delay: calc(var(--i) * 80ms) makes the cards arrive one after another when the page loads. Hovering lifts the card and tilts the product a little.

Six product cards on a cream background: sneakers, headphones, a watch, sunglasses, a backpack and a camera, with the cart badge showing 3
The shop: one button turns green and says Added after a click.

2.3 Two layers for a curved flight

style.css: flying item
/* ---------- FLYING ITEM ---------- */
/* Two layers: .fly moves left-right at a steady speed,
   .fly-inner moves up-down with easing. Together they draw an arc. */
.fly, .fly-inner { position: fixed; top: 0; left: 0; pointer-events: none; z-index: 50; }
.fly-inner { position: static; display: block; width: 60px; height: 60px; font-size: 52px; line-height: 60px; text-align: center; }
/* ---------- END FLYING ITEM ---------- */

This is the key idea of the whole tutorial. A straight line from the product to the cart looks robotic. Things thrown through the air move in an arc: steady sideways, while going up and then down.

So the flying item is two nested elements:

Each layer runs its own simple animation. Combined, they trace a smooth curve, which is very hard to do with one element and one animation.

2.4 The cart drawer

style.css: cart drawer
/* ---------- CART DRAWER ---------- */
.backdrop {
  position: fixed;
  inset: 0;
  z-index: 20;
  background: rgba(31, 26, 23, .45);
  backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity .3s;
}
.backdrop.show { opacity: 1; }

.drawer {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: 30;
  width: min(400px, 100%);
  display: flex;
  flex-direction: column;
  background: var(--bg);
  box-shadow: -20px 0 60px rgba(0, 0, 0, .2);
  transform: translateX(calc(100% + 80px));   /* + 80px hides the shadow too */
  transition: transform .45s cubic-bezier(.2, .9, .3, 1);
}
.drawer.open { transform: none; }

.drawer-head { display: flex; justify-content: space-between; align-items: center; padding: 20px 22px 10px; }
.drawer-head h2 { font-size: 22px; }
.close { width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .06); font-size: 15px; transition: transform .3s; }
.close:hover { transform: rotate(90deg); }

.ship { padding: 6px 22px 14px; border-bottom: 1px solid rgba(0, 0, 0, .07); }
.ship-text { font-size: 13px; color: var(--muted); margin-bottom: 8px; }
.ship-text b { color: var(--ink); }
.ship-bar { height: 8px; border-radius: 4px; background: rgba(0, 0, 0, .08); overflow: hidden; }
.ship-bar span {
  display: block;
  height: 100%;
  width: var(--p, 0%);
  border-radius: 4px;
  background: linear-gradient(90deg, #ffb547, var(--accent));
  transition: width .6s cubic-bezier(.2, .9, .3, 1), background .4s;
}
.ship.done .ship-bar span { background: linear-gradient(90deg, #4ade80, var(--ok)); }
.ship.done .ship-text { color: var(--ok); font-weight: 600; }

.items { list-style: none; flex: 1; overflow-y: auto; padding: 8px 22px; }
.item {
  display: grid;
  grid-template-columns: 58px 1fr auto;
  gap: 12px;
  align-items: center;
  padding: 12px 0;
  border-bottom: 1px solid rgba(0, 0, 0, .06);
  animation: slide-in .4s cubic-bezier(.2, .9, .3, 1.2);
}
.item-thumb { width: 58px; height: 58px; border-radius: 12px; display: grid; place-items: center; font-size: 32px; background: var(--tint); }
.item h3 { font-size: 15px; }
.item .each { font-size: 13px; color: var(--muted); }

.qty { display: inline-flex; align-items: center; gap: 2px; margin-top: 6px; border-radius: 10px; background: rgba(0, 0, 0, .05); }
.qty button { width: 28px; height: 28px; border: 0; background: none; font-size: 16px; font-weight: 700; border-radius: 8px; }
.qty button:hover { background: rgba(0, 0, 0, .07); }
.qty output { min-width: 20px; text-align: center; font-weight: 700; font-variant-numeric: tabular-nums; }
.qty output.tick { animation: tick .3s ease; }

.item-right { text-align: right; }
.line { display: block; font-weight: 800; font-variant-numeric: tabular-nums; }
.remove { margin-top: 6px; border: 0; background: none; font-size: 12px; color: var(--muted); text-decoration: underline; }
.remove:hover { color: var(--accent-dark); }

.empty { display: none; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 12px; color: var(--muted); }
.drawer.is-empty .empty { display: flex; }
.drawer.is-empty .items, .drawer.is-empty .drawer-foot, .drawer.is-empty .ship { display: none; }
.empty-bag { font-size: 64px; animation: float 2.4s ease-in-out infinite; }
.keep { padding: 10px 18px; border: 2px solid var(--ink); border-radius: 12px; background: none; font-weight: 700; }

.drawer-foot { padding: 16px 22px 22px; border-top: 1px solid rgba(0, 0, 0, .07); }
.total { display: flex; justify-content: space-between; margin-bottom: 14px; font-size: 17px; }
.subtotal { font-variant-numeric: tabular-nums; }

.checkout {
  position: relative;
  width: 100%;
  height: 54px;
  border: 0;
  border-radius: 14px;
  background: var(--accent);
  color: #fff;
  font-weight: 800;
  font-size: 16px;
  overflow: hidden;
  transition: background .3s;
}
.checkout:hover { background: var(--accent-dark); }
.checkout .truck {
  position: absolute;
  top: 50%;
  left: -40px;
  font-size: 26px;
  transform: translateY(-50%) scaleX(-1);   /* the emoji faces left; flip it to drive right */
}
.checkout .label { transition: opacity .2s; }
.checkout.going { background: var(--ink); }
.checkout.going .label { opacity: 0; }           /* the truck is the message now */
.checkout.going .truck { animation: drive 1.6s cubic-bezier(.5, 0, .5, 1) forwards; }
.checkout.done { background: var(--ok); }
/* ---------- END CART DRAWER ---------- */

The drawer waits at translateX(calc(100% + 80px)). The extra 80px also pushes its big shadow off screen; with just 100%, a dark edge would peek in at the side of the page. Adding .open slides it to none.

The shipping bar's width comes from var(--p), which JavaScript sets as a percentage. Its transition makes it grow smoothly, and .ship.done switches it to green.

The cart drawer header saying You are $76.00 away from free shipping, with an orange bar half full
The shipping bar tells people how much more they need to spend.

The empty state uses a neat trick: one class on the drawer, .is-empty, shows the empty message and hides the list, shipping bar and footer. JavaScript only toggles that class.

The checkout truck sits just off the left edge of the button. During .going, the label fades out and the drive animation moves the truck to the right edge. The truck emoji faces left on most systems, so scaleX(-1) mirrors it.

2.5 Keyframes and reduced motion

style.css: keyframes and reduced motion
/* ---------- KEYFRAMES ---------- */
@keyframes enter { from { opacity: 0; transform: translateY(30px) scale(.95); } }
@keyframes wiggle { 0%, 100% { transform: rotate(0); } 25% { transform: rotate(-14deg) scale(1.1); } 50% { transform: rotate(10deg) scale(1.1); } 75% { transform: rotate(-6deg); } }
@keyframes bump { 50% { transform: scale(1.5); } }
@keyframes tick { 0% { transform: translateY(-8px); opacity: 0; } }
@keyframes slide-in { from { opacity: 0; transform: translateX(40px); } }
@keyframes float { 50% { transform: translateY(-10px) rotate(4deg); } }
@keyframes drive { to { left: calc(100% + 40px); } }
/* ---------- END KEYFRAMES ---------- */

/* ---------- REDUCED MOTION ---------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0s !important;
    transition-duration: .01ms !important;
  }
}
/* ---------- END REDUCED MOTION ---------- */

Step 3: The JavaScript

3.1 Products as data

script.js: settings and elements
// ---------- SETTINGS ----------
const FREE_SHIPPING = 150;   // dollars
const PRODUCTS = [
  { id: 'sneaker',  name: 'Cloud Runner',  price: 89,  emoji: '👟', tint: '#ffe3d3' },
  { id: 'phones',   name: 'Bass Buds Max', price: 129, emoji: '🎧', tint: '#dfe9ff' },
  { id: 'watch',    name: 'Tempo Watch',   price: 199, emoji: '⌚', tint: '#e5f7e8' },
  { id: 'shades',   name: 'Sunny Shades',  price: 59,  emoji: '🕶️', tint: '#fff1c9' },
  { id: 'pack',     name: 'Trail Pack',    price: 74,  emoji: '🎒', tint: '#ffdde6' },
  { id: 'camera',   name: 'Snap Mini',     price: 349, emoji: '📷', tint: '#ece3ff' }
];
// ---------- END SETTINGS ----------

// ---------- ELEMENTS ----------
const grid = document.querySelector('.grid');
const cartBtn = document.querySelector('.cart-btn');
const badge = document.querySelector('.badge');
const drawer = document.querySelector('.drawer');
const backdrop = document.querySelector('.backdrop');
const list = document.querySelector('.items');
const subtotalEl = document.querySelector('.subtotal');
const ship = document.querySelector('.ship');
const shipText = document.querySelector('.ship-text');
const shipBar = document.querySelector('.ship-bar span');
const checkout = document.querySelector('.checkout');
const live = document.querySelector('[role="status"]');
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;

const money = (n) => '$' + n.toFixed(2);
const find = (id) => PRODUCTS.find((p) => p.id === id);
// ---------- END ELEMENTS ----------

Every product is an object with an id, name, price, emoji and tile colour. To add a product to the shop, add a line to PRODUCTS; nothing else needs to change. money() formats numbers as dollars, and find() looks up a product by its id.

3.2 Rendering the grid

script.js: product grid
// ---------- PRODUCT GRID ----------
grid.innerHTML = PRODUCTS.map((p, i) => `
  <li class="product" style="--i:${i}; --tint:${p.tint}">
    <div class="thumb"><span class="emoji" aria-hidden="true">${p.emoji}</span></div>
    <div class="info"><h3>${p.name}</h3><span class="price">${money(p.price)}</span></div>
    <button type="button" class="add" data-id="${p.id}">Add to cart</button>
  </li>`).join('');

grid.addEventListener('click', (e) => {
  const btn = e.target.closest('.add');
  if (!btn) return;
  const product = find(btn.dataset.id);
  const emoji = btn.closest('.product').querySelector('.emoji');

  // Button feedback: "Added ✓" for a moment.
  btn.classList.add('added');
  btn.textContent = 'Added ✓';
  clearTimeout(btn.timer);
  btn.timer = setTimeout(() => { btn.classList.remove('added'); btn.textContent = 'Add to cart'; }, 1200);

  flyToCart(emoji, () => add(product.id));
  live.textContent = `${product.name} added to cart.`;
});
// ---------- END PRODUCT GRID ----------

The grid is built from PRODUCTS with a template string. Instead of one listener per button, a single listener on the grid catches every click and uses e.target.closest('.add') to find which button was pressed. This is event delegation, and it keeps working even if you add products later.

The button turns green and says “Added ✓” for 1.2 seconds. Saving the timer on the button (btn.timer) means fast repeat clicks restart the countdown instead of stacking up.

3.3 The flight

script.js: fly to cart
// ---------- FLY TO CART ----------
function flyToCart(fromEl, done) {
  if (reduceMotion) { done(); return; }
  const from = fromEl.getBoundingClientRect();
  const to = cartBtn.getBoundingClientRect();

  const fly = document.createElement('span');
  fly.className = 'fly';
  const inner = document.createElement('span');
  inner.className = 'fly-inner';
  inner.textContent = fromEl.textContent;
  fly.appendChild(inner);
  document.body.appendChild(fly);

  // Start where the product picture is, end on the cart (30 = half the 60px emoji).
  const x1 = from.left + from.width / 2 - 30, y1 = from.top + from.height / 2 - 30;
  const x2 = to.left + to.width / 2 - 30,    y2 = to.top + to.height / 2 - 30;
  const time = 800;

  // Left-right: steady speed.
  fly.animate(
    [{ transform: `translateX(${x1}px)` }, { transform: `translateX(${x2}px)` }],
    { duration: time, easing: 'linear', fill: 'forwards' }
  );
  // Up-down: rise first, then drop into the cart. Shrink and spin on the way.
  // The top of the arc stays inside the window (never above 0).
  const peak = Math.max(0, Math.min(y1 - 120, y2 - 40));
  inner.animate([
    { transform: `translateY(${y1}px) scale(1) rotate(0)` },
    { transform: `translateY(${peak}px) scale(.8) rotate(-20deg)`, offset: 0.4, easing: 'ease-in' },
    { transform: `translateY(${y2}px) scale(.25) rotate(20deg)` }
  ], { duration: time, easing: 'ease-out', fill: 'forwards' }).onfinish = () => {
    fly.remove();
    done();
    cartBtn.classList.remove('wiggle');
    void cartBtn.offsetWidth;
    cartBtn.classList.add('wiggle');
  };
}
// ---------- END FLY TO CART ----------

Step by step:

  1. getBoundingClientRect() gives the screen position of the product picture and the cart button.
  2. A copy of the emoji is created as the two-layer .fly element.
  3. The outer layer animates sideways from x1 to x2 with linear timing.
  4. The inner layer animates through three keyframes: the start, a peak above both points at 40% of the time, and the cart. It shrinks to a quarter of its size and spins on the way.
  5. When it lands, onfinish removes the copy, adds the product to the cart and wiggles the cart.

The peak is Math.max(0, …), which keeps it inside the window. The cart is near the top of the screen, so without this the item would fly off the top and drop back in.

These use element.animate(), the Web Animations API: keyframes written in JavaScript, so each flight can use its own start and end points. With reduced motion turned on, the flight is skipped and the item goes straight into the cart.

3.4 Cart state

script.js: cart state
// ---------- CART STATE ----------
// cart looks like { sneaker: 2, watch: 1 }
let cart = {};

function add(id) { cart[id] = (cart[id] || 0) + 1; render(id); }
function change(id, by) {
  cart[id] += by;
  if (cart[id] <= 0) removeItem(id);
  else render(id);
}
function removeItem(id) {
  const row = list.querySelector(`[data-id="${id}"]`);
  delete cart[id];
  if (!row || reduceMotion) { render(); return; }
  // Measure the row, then shrink it to nothing while it slides away.
  const h = row.offsetHeight;
  row.animate([
    { height: h + 'px', opacity: 1, transform: 'none' },
    { height: '0px', opacity: 0, transform: 'translateX(60px)', paddingTop: 0, paddingBottom: 0 }
  ], { duration: 320, easing: 'ease-in' }).onfinish = () => render();
}

const count = () => Object.values(cart).reduce((a, b) => a + b, 0);
const subtotal = () => Object.entries(cart).reduce((sum, [id, q]) => sum + find(id).price * q, 0);
// ---------- END CART STATE ----------

The whole cart is one plain object: { sneaker: 2, watch: 1 }. Every action changes that object, then calls render() to update the screen. Keeping one source of truth like this is how frameworks such as React work, and it avoids the screen and the data ever disagreeing.

Removing an item animates first. The row's height is measured with offsetHeight, because CSS can't animate from height: auto. The animation goes from that exact pixel height to 0, while fading and sliding right, so the rows below glide up instead of jumping.

3.5 Drawing the cart

script.js: draw the cart
// ---------- DRAW THE CART ----------
// `changed` is the product whose number just changed, so only that one animates.
function render(changed) {
  const ids = Object.keys(cart);
  drawer.classList.toggle('is-empty', ids.length === 0);

  // Add rows that are new, update rows that exist, drop rows that are gone.
  ids.forEach((id) => {
    const p = find(id);
    let row = list.querySelector(`[data-id="${id}"]`);
    if (!row) {
      row = document.createElement('li');
      row.className = 'item';
      row.dataset.id = id;
      row.innerHTML = `
        <span class="item-thumb" style="--tint:${p.tint}" aria-hidden="true">${p.emoji}</span>
        <div>
          <h3>${p.name}</h3>
          <span class="each">${money(p.price)} each</span><br>
          <span class="qty">
            <button type="button" data-by="-1" aria-label="One fewer ${p.name}">−</button>
            <output aria-label="Quantity"></output>
            <button type="button" data-by="1" aria-label="One more ${p.name}">+</button>
          </span>
        </div>
        <div class="item-right">
          <span class="line"></span>
          <button type="button" class="remove">Remove</button>
        </div>`;
      list.appendChild(row);
    }
    const out = row.querySelector('output');
    out.textContent = cart[id];
    row.querySelector('.line').textContent = money(p.price * cart[id]);
    if (id === changed) { out.classList.remove('tick'); void out.offsetWidth; out.classList.add('tick'); }
  });
  list.querySelectorAll('.item').forEach((row) => { if (!(row.dataset.id in cart)) row.remove(); });

  // Badge in the header.
  const n = count();
  badge.textContent = n;
  badge.classList.toggle('has', n > 0);
  badge.classList.remove('bump'); void badge.offsetWidth; if (n) badge.classList.add('bump');
  cartBtn.setAttribute('aria-label', `Open cart, ${n} item${n === 1 ? '' : 's'}`);

  // Money.
  const total = subtotal();
  countUp(subtotalEl, total);
  const left = FREE_SHIPPING - total;
  ship.classList.toggle('done', left <= 0);
  shipText.innerHTML = left > 0
    ? `You are <b>${money(left)}</b> away from free shipping`
    : '🎉 You unlocked free shipping!';
  shipBar.style.setProperty('--p', Math.min(100, (total / FREE_SHIPPING) * 100) + '%');
}

// Roll the number from its old value to the new one.
function countUp(el, to) {
  const from = parseFloat(el.textContent.replace(/[^0-9.]/g, '')) || 0;
  cancelAnimationFrame(el.raf);
  if (reduceMotion) { el.textContent = money(to); return; }
  const start = performance.now(), time = 450;
  const step = (now) => {
    const t = Math.min(1, (now - start) / time);
    const eased = 1 - Math.pow(1 - t, 3);
    el.textContent = money(from + (to - from) * eased);
    if (t < 1) el.raf = requestAnimationFrame(step);
  };
  el.raf = requestAnimationFrame(step);
}

list.addEventListener('click', (e) => {
  const row = e.target.closest('.item');
  if (!row) return;
  const id = row.dataset.id;
  if (!(id in cart)) return;            // already sliding away
  const step = e.target.closest('[data-by]');
  if (step) change(id, Number(step.dataset.by));
  if (e.target.closest('.remove')) {
    live.textContent = `${find(id).name} removed.`;
    removeItem(id);
  }
});
// ---------- END DRAW THE CART ----------
The cart drawer open over the blurred shop, listing sneakers, a watch and two sunglasses with quantity buttons, free shipping unlocked and a subtotal of $406.00
The open cart: rows with quantity buttons, the shipping bar and the total.

render() doesn't rebuild the whole list each time. It adds rows that are new, updates the numbers in rows that already exist, and removes rows that are gone. That way, a row that's still on screen keeps its focus and isn't re-animated. Only the quantity that changed gets the little tick animation.

It also updates the badge, the button label, the free shipping message and the bar width.

countUp() rolls the subtotal from its old value to the new one over 450ms. The easing 1 - (1 - t)³ starts fast and slows down at the end, so the final number settles gently.

The quantity buttons use the same delegation idea: one listener on the list reads data-by (+1 or -1) from the button that was clicked. The check if (!(id in cart)) return ignores clicks on a row that's already sliding away.

3.6 Opening and closing the drawer

script.js: open and close the drawer
// ---------- OPEN AND CLOSE THE DRAWER ----------
let lastFocus = null;

function openCart() {
  lastFocus = document.activeElement;
  backdrop.hidden = false;
  drawer.inert = false;
  document.body.classList.add('locked');
  requestAnimationFrame(() => { backdrop.classList.add('show'); drawer.classList.add('open'); });
  cartBtn.setAttribute('aria-expanded', 'true');
  drawer.querySelector('.close').focus();
}

function closeCart() {
  backdrop.classList.remove('show');
  drawer.classList.remove('open');
  drawer.inert = true;
  document.body.classList.remove('locked');
  cartBtn.setAttribute('aria-expanded', 'false');
  setTimeout(() => { backdrop.hidden = true; }, reduceMotion ? 0 : 300);
  if (lastFocus) lastFocus.focus();
}

cartBtn.addEventListener('click', openCart);
backdrop.addEventListener('click', closeCart);
drawer.querySelector('.close').addEventListener('click', closeCart);
drawer.querySelector('.keep').addEventListener('click', closeCart);

// Esc closes; Tab stays inside the drawer while it is open.
document.addEventListener('keydown', (e) => {
  if (drawer.inert) return;             // cart is closed
  if (e.key === 'Escape') closeCart();
  if (e.key === 'Tab') {
    const f = [...drawer.querySelectorAll('button')].filter((b) => b.offsetParent !== null);
    const first = f[0], last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  }
});
// ---------- END OPEN AND CLOSE THE DRAWER ----------

A good drawer behaves like a dialog:

The classes are added inside requestAnimationFrame so the browser draws the backdrop once before it fades in, otherwise the transition would be skipped.

3.7 The truck checkout

script.js: checkout
// ---------- CHECKOUT ----------
checkout.addEventListener('click', () => {
  if (checkout.classList.contains('going')) return;
  const label = checkout.querySelector('.label');
  checkout.classList.add('going');
  label.textContent = 'Placing order…';

  // Replace this timeout with a fetch() to your payment or order API.
  setTimeout(() => {
    checkout.classList.remove('going');
    checkout.classList.add('done');
    label.textContent = 'Order placed ✓';
    live.textContent = 'Order placed. Thank you!';
    setTimeout(() => {
      cart = {};
      render();
      drawer.querySelector('.keep').focus();   // the checkout button is hidden now
      checkout.classList.remove('done');
      label.textContent = 'Checkout';
    }, 1500);
  }, reduceMotion ? 300 : 1700);
});
// ---------- END CHECKOUT ----------

render();
The dark checkout button with a delivery truck in the middle of it, under a subtotal
Checkout: the truck drives across the button.

Clicking Checkout adds .going, which starts the truck. When it reaches the other side, the button turns green with “Order placed ✓”, and the message is announced to screen readers. 1.5 seconds later the cart empties. Focus moves to the “Keep shopping” button, because the checkout button has just disappeared and focus must never be left on a hidden element.

Test it yourself

  1. Add the sunglasses. They fly into the cart and the badge pops to 1.
  2. Add the sneakers too, then open the cart. Free shipping is unlocked.
  3. Press minus on the sunglasses until the row slides away. The bar drops back to orange.
  4. Press Tab repeatedly. Focus stays inside the drawer. Press Esc to close it.
  5. Add the camera and check out. Watch the truck.

Using it on a real site

Share your shop

Built your own store page? Share a screenshot as a link with sshot.online, or share your code with the free code sharing tool. No account needed.

Conclusion

Small animations make a shop feel alive, and each one here also tells people something useful:

Underneath, it's a few reusable ideas: two layers for a curved path, one object as the source of truth, event delegation, and a drawer that treats keyboard users properly.

Frequently asked questions

How do I make a product fly into the cart in a curve?

Use two nested elements. Animate the outer one sideways at a steady speed and the inner one up and then down with easing. Together the two movements trace a smooth arc.

Does the add to cart animation need a library?

No. It uses the built-in Web Animations API, element.animate(), which works in all modern browsers.

How do I animate removing an item from a list?

Measure the row with offsetHeight, then animate from that pixel height to 0 while fading it out. CSS cannot animate from height: auto, so the measured height is needed.

How do I make the cart drawer accessible?

Give it role="dialog" and aria-modal, keep it inert while closed, move focus into it when it opens, trap Tab inside it, close it with Escape and return focus to where it was.

Can I use real product photos instead of emoji?

Yes. Put an <img> tag in each card and clone the image for the flying element instead of copying the emoji text. The rest of the code stays the same.

Related articles

← All guides