How to make an add to cart animation in React (fly to cart)

Tap Add and a copy of the product photo flies in a curve to the cart button, which bumps and shows the new count. It takes one small function with the Web Animations API, no animation library, and it works the same in Next.js, Vite and any React app.

By Teodor Avadani · Updated October 25, 2026

How it works

Three steps. Measure where the product image and the cart button are on screen. Put a fixed copy of the image on top of the product and move it to the cart along a curve. When it lands, remove the copy, bump the cart button and update the badge.

The copy is a plain <img> added to document.body with position: fixed, so no parent with overflow: hidden can clip it and the layout never moves. Nothing here touches React state while it flies, so the page does not re-render 60 times a second.

The flyToCart function

A straight line from the product to the cart looks mechanical. This function samples a curve (a quadratic Bézier) into keyframes: the image rises first, then drops into the cart while it shrinks. element.animate() runs it on the compositor, and its finished promise tells you when it landed.

flyToCart.ts
/**
 * Flies a round copy of the product image from `from` to `to` along a curve,
 * then removes it. Resolves when it lands (right away with reduced motion).
 */
export function flyToCart(from: HTMLElement, to: HTMLElement, src: string): Promise<void> {
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    return Promise.resolve();
  }

  const a = from.getBoundingClientRect();
  const b = to.getBoundingClientRect();
  const size = Math.min(a.width, a.height, 96);

  const ghost = document.createElement("img");
  ghost.src = src;
  ghost.alt = "";
  ghost.setAttribute("aria-hidden", "true");
  Object.assign(ghost.style, {
    position: "fixed",
    left: `${a.left + a.width / 2 - size / 2}px`,
    top: `${a.top + a.height / 2 - size / 2}px`,
    width: `${size}px`,
    height: `${size}px`,
    borderRadius: "9999px",
    objectFit: "cover",
    pointerEvents: "none",
    zIndex: "60",
  });
  document.body.appendChild(ghost);

  // Distance from the image center to the cart center.
  const dx = b.left + b.width / 2 - (a.left + a.width / 2);
  const dy = b.top + b.height / 2 - (a.top + a.height / 2);
  // The curve's control point sits above both ends, so the image rises, then drops in.
  const cx = dx / 2;
  const cy = Math.min(0, dy) - 80;

  const frames: Keyframe[] = [];
  const steps = 24;
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    const x = 2 * (1 - t) * t * cx + t * t * dx;
    const y = 2 * (1 - t) * t * cy + t * t * dy;
    frames.push({ transform: `translate(${x}px, ${y}px) scale(${1 - 0.8 * t})`, opacity: t > 0.85 ? 0.5 : 1 });
  }

  const flight = ghost.animate(frames, { duration: 650, easing: "cubic-bezier(.45,0,.55,1)" });
  return flight.finished.then(
    () => ghost.remove(),
    () => ghost.remove(), // cancelled, for example when the page changes
  );
}

With reduced motion turned on, the function returns at once and nothing flies; the badge still updates. Cancelled animations (for example when the page changes mid flight) also remove the copy, so no stray image is left behind.

The product grid and the cart button

Each card keeps a ref to its image and hands it to add. The cart button keeps a ref too, so the function knows where to fly. Write to your cart first and animate after: the cart is right even if someone leaves the page mid flight, and only the badge waits for the landing.

Shop.tsx
"use client";

import { useRef, useState } from "react";
import { flyToCart } from "./flyToCart";

export type Product = { id: string; name: string; image: string };

export function Shop({ products }: { products: Product[] }) {
  const cartRef = useRef<HTMLButtonElement>(null);
  const [cart, setCart] = useState<string[]>([]); // what is really in the cart
  const [shown, setShown] = useState(0); // what the badge shows, counted on landing
  const [said, setSaid] = useState("");

  async function add(product: Product, image: HTMLElement | null) {
    setCart((c) => [...c, product.id]); // save first, animate after
    setSaid(`${product.name} added to cart`);
    if (image && cartRef.current) await flyToCart(image, cartRef.current, product.image);
    setShown((n) => n + 1);
    cartRef.current?.animate(
      [{ transform: "scale(1)" }, { transform: "scale(1.18)" }, { transform: "scale(1)" }],
      { duration: 320, easing: "ease-out" },
    );
  }

  return (
    <div className="min-h-dvh bg-zinc-950 text-zinc-100">
      <header className="sticky top-0 z-10 flex items-center justify-between border-b border-zinc-800 bg-zinc-950/80 px-5 py-3 backdrop-blur">
        <span className="font-semibold">Store</span>
        <button
          ref={cartRef}
          type="button"
          aria-label={`Cart, ${cart.length} ${cart.length === 1 ? "item" : "items"}`}
          className="relative grid size-11 place-items-center rounded-full border border-zinc-700 bg-zinc-900"
        >
          <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2" 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" />
            <circle cx="10" cy="20" r="1" />
            <circle cx="17" cy="20" r="1" />
          </svg>
          {shown > 0 && (
            <span className="absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-emerald-300 px-1 text-xs font-semibold text-zinc-950">
              {shown}
            </span>
          )}
        </button>
      </header>

      <p aria-live="polite" className="sr-only">{said}</p>

      <ul className="grid grid-cols-2 gap-4 p-5 sm:grid-cols-3">
        {products.map((p) => (
          <ProductCard key={p.id} product={p} onAdd={add} />
        ))}
      </ul>
    </div>
  );
}

function ProductCard({ product, onAdd }: { product: Product; onAdd: (p: Product, image: HTMLElement | null) => void }) {
  const imageRef = useRef<HTMLImageElement>(null);
  return (
    <li className="rounded-2xl border border-zinc-800 bg-zinc-900 p-3">
      {/* eslint-disable-next-line @next/next/no-img-element */}
      <img ref={imageRef} src={product.image} alt={product.name} className="aspect-square w-full rounded-xl object-cover" />
      <div className="mt-3 flex items-center justify-between gap-2">
        <span className="text-sm font-medium">{product.name}</span>
        <button
          type="button"
          onClick={() => onAdd(product, imageRef.current)}
          className="rounded-full bg-emerald-300 px-3 py-1.5 text-sm font-medium text-zinc-950 transition active:scale-95"
        >
          Add
        </button>
      </div>
    </li>
  );
}

The bump on the cart button is one more animate() call: scale up 18% and back in 320 ms. The cart button's aria-label carries the real count, and a polite live region says what was added, so screen reader users hear the add even though the flight is only visual.

Use it on a page

The page can stay a server component. Pass it your products.

app/shop/page.tsx
import { Shop } from "@/components/shop/Shop";

const products = [
  { id: "mug", name: "Mug", image: "/products/mug.jpg" },
  { id: "lamp", name: "Lamp", image: "/products/lamp.jpg" },
  { id: "card", name: "Card", image: "/products/card.jpg" },
];

export default function ShopPage() {
  return <Shop products={products} />;
}

What we checked

In Chrome at 390px wide, in a Next.js 15.5 app with React 19 and Tailwind v4: tapping Add puts one flying copy on the page, it follows a curve and lands on the cart button's center after about 650 ms, then it is removed. The aria-label read "Cart, 1 item" right after the tap, and the badge showed 1 on landing. Two quick taps on different products gave 3 items, a badge of 3 and no copies left. With reduced motion on, no copy appeared and the badge updated at once.

Using Cursor, v0 or Lovable

Cursor: paste flyToCart.ts and ask it to call flyToCart(imageEl, cartButtonEl, imageSrc) from your existing add to cart handler, after your cart store is updated.

v0: generated stores often animate with a CSS class on the product card. Ask v0 for a fixed position copy of the image animated with the Web Animations API, and paste this function as the shape you want.

Lovable: projects are React with Vite, and nothing here needs Next.js, so both files work as they are. Drop the "use client" line if you like; Vite ignores it.

Or use the ready-made piece

If you want the whole store moment finished, Stack Cart Drawer goes further than this guide: the photo shrinks into a spinning disc that lands on a small stack on a glass cart button, the total rolls like an odometer, a ring fills toward free shipping, and the drawer unfolds each disc into a line item. Checkout folds the lines back into discs and flies them home.

Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.

Questions

How do I make an add to cart animation in React?
Measure the product image and the cart button with getBoundingClientRect, put a fixed copy of the image on top of the product, animate it to the cart with element.animate(), then remove it and bump the cart button.
Do I need Framer Motion for a fly to cart animation?
No. The Web Animations API built into the browser moves a fixed image along keyframes and gives you a finished promise for the landing.
How do I make the image fly in a curve, not a straight line?
Sample a quadratic Bézier curve into keyframes, with the control point above both the product and the cart. The image rises first, then drops in.
Should the cart count update before or after the animation?
Save the item to the cart right away so the data is correct, and let only the badge wait for the landing. Announce the add in a live region for screen readers.

Read next

Add to Cart Animation in React: Fly to Cart, No Library