A small storefront in a bold yellow and black poster style that shows the whole add to cart moment. Pressing Add sends a copy of the drawn bottle up and over in an arc into the bag button. When it lands the button kicks, a square ring pulses out, the count rolls to the next number and the bag panel grows a new row or pops the quantity. A free delivery bar fills as the subtotal climbs. It adds a few bottles by itself until a visitor presses something. On phones the products become rows and the bag panel moves underneath.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useAnimationControls, useInView, useReducedMotion } from "framer-motion";
type Product = { id: string; name: string; note: string; price: number; juice: string; tile: string };
const products: Product[] = [
{ id: "sun", name: "Sunrise", note: "Orange, carrot, turmeric", price: 6.5, juice: "#fb923c", tile: "bg-orange-100" },
{ id: "green", name: "Deep Green", note: "Kale, apple, lime", price: 7, juice: "#65a30d", tile: "bg-lime-100" },
{ id: "beet", name: "Beet Up", note: "Beetroot, ginger, pear", price: 7.5, juice: "#e11d48", tile: "bg-rose-100" },
];
const FREE_DELIVERY = 30;
/** The self-playing demo adds these in turn, then empties the bag and starts over. */
const DEMO = ["green", "sun", "beet", "green"];
const money = (n: number) => "$" + n.toFixed(2);
function Bottle({ juice }: { juice: string }) {
return (
<svg viewBox="0 0 80 124" className="h-full w-full" aria-hidden="true">
<rect x="29" y="3" width="22" height="12" fill="#0a0a0a" />
<rect x="33" y="15" width="14" height="10" fill="#fff" stroke="#0a0a0a" strokeWidth="3" />
<path d="M21 42Q21 27 33 25H47Q59 27 59 42V108Q59 119 48 119H32Q21 119 21 108Z" fill="#fff" stroke="#0a0a0a" strokeWidth="3" strokeLinejoin="round" />
<path d="M24 46H56V108Q56 116 48 116H32Q24 116 24 108Z" fill={juice} />
<rect x="21" y="62" width="38" height="28" fill="#fff" stroke="#0a0a0a" strokeWidth="3" />
<rect x="27" y="69" width="26" height="4" fill="#0a0a0a" />
<rect x="27" y="77" width="16" height="3" fill="#0a0a0a" fillOpacity="0.5" />
<rect x="27" y="48" width="4" height="10" fill="#fff" fillOpacity="0.6" />
</svg>
);
}
type Flyer = { key: number; id: string; x: number; y: number; w: number; h: number; dx: number; dy: number };
export default function AddToCartWithItemFlyingIntoCartAndCountBump() {
const reduce = useReducedMotion();
const [bag, setBag] = useState<{ id: string; qty: number }[]>([{ id: "sun", qty: 1 }]);
const [flyers, setFlyers] = useState<Flyer[]>([]);
const [pressed, setPressed] = useState<string | null>(null);
const [auto, setAuto] = useState(true);
const [step, setStep] = useState(0);
const frame = useRef<HTMLDivElement>(null);
const cart = useRef<HTMLButtonElement>(null);
const art = useRef<Record<string, HTMLDivElement | null>>({});
const serial = useRef(0);
const pressTimer = useRef<number | undefined>(undefined);
const bump = useAnimationControls();
const visible = useInView(frame, { amount: 0.3 });
const count = bag.reduce((n, l) => n + l.qty, 0);
const total = bag.reduce((n, l) => n + l.qty * (products.find((p) => p.id === l.id)?.price ?? 0), 0);
const left = Math.max(0, FREE_DELIVERY - total);
const land = (id: string) => {
setBag((b) => (b.some((l) => l.id === id) ? b.map((l) => (l.id === id ? { ...l, qty: l.qty + 1 } : l)) : [...b, { id, qty: 1 }]));
if (!reduce) bump.start({ scale: [1, 1.14, 0.94, 1], rotate: [0, -4, 3, 0], transition: { duration: 0.5, ease: "easeOut" } });
};
const add = (id: string) => {
setPressed(id);
window.clearTimeout(pressTimer.current);
pressTimer.current = window.setTimeout(() => setPressed(null), 1100);
const from = art.current[id]?.getBoundingClientRect();
const to = cart.current?.getBoundingClientRect();
const base = frame.current?.getBoundingClientRect();
if (reduce || !from || !to || !base) return land(id);
serial.current += 1;
setFlyers((f) => [
...f,
{
key: serial.current,
id,
x: from.left - base.left,
y: from.top - base.top,
w: from.width,
h: from.height,
dx: to.left + to.width / 2 - (from.left + from.width / 2),
dy: to.top + to.height / 2 - (from.top + from.height / 2),
},
]);
};
// Self demo, until a visitor presses something.
useEffect(() => {
if (!auto || reduce || !visible) return;
const t = window.setTimeout(
() => {
if (step >= DEMO.length) {
setBag([{ id: "sun", qty: 1 }]);
setStep(0);
} else {
add(DEMO[step]);
setStep((s) => s + 1);
}
},
step === 0 ? 750 : step >= DEMO.length ? 2800 : 2050,
);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [auto, reduce, visible, step]);
useEffect(() => () => window.clearTimeout(pressTimer.current), []);
return (
<section className="bg-yellow-300 px-4 py-14 sm:px-6 sm:py-16">
<style>{`@keyframes add-to-cart-fly-ring { from { transform: scale(1); opacity: 0.9 } to { transform: scale(2.1); opacity: 0 } }`}</style>
<div ref={frame} className="relative mx-auto max-w-5xl border-2 border-black bg-white shadow-[6px_6px_0_0_#000] sm:shadow-[10px_10px_0_0_#000]">
<header className="flex items-center justify-between gap-4 border-b-2 border-black px-4 py-3 sm:px-6">
<p className="text-lg font-black uppercase tracking-tight text-black">Pulp & Co.</p>
<nav aria-label="Shop" className="hidden gap-6 text-sm font-bold text-black sm:flex">
<a href="#" className="underline decoration-2 underline-offset-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-black">Cold pressed</a>
<a href="#" className="hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-black">Shots</a>
<a href="#" className="hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-black">Bundles</a>
</nav>
<motion.button
ref={cart}
type="button"
animate={bump}
aria-label={`Bag, ${count} ${count === 1 ? "item" : "items"}`}
className="relative flex h-11 items-center gap-2 border-2 border-black bg-yellow-300 pl-3 pr-2 text-sm font-black text-black focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2"
>
{!reduce && count > 1 && <span key={count} aria-hidden="true" className="pointer-events-none absolute inset-0 border-2 border-black animate-[add-to-cart-fly-ring_0.6s_ease-out_forwards]" />}
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinejoin="round" aria-hidden="true">
<path d="M4 8h16l-1.5 12h-13zM8.5 8V6.5a3.5 3.5 0 0 1 7 0V8" />
</svg>
Bag
<span className="relative flex h-7 min-w-[28px] items-center justify-center overflow-hidden bg-black px-1.5 tabular-nums text-yellow-300">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={count}
initial={reduce ? false : { y: 18, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0 } : { y: -18, opacity: 0 }}
transition={{ type: "spring", stiffness: 520, damping: 26 }}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</motion.button>
</header>
<div className="grid lg:grid-cols-[1fr_320px]">
<div className="p-4 sm:p-6">
<h2 className="text-3xl font-black uppercase leading-none tracking-tight text-black sm:text-4xl">Pressed this morning</h2>
<p className="mt-2 max-w-md text-base font-medium text-neutral-700">Three bottles, bottled at six, at your door by noon. Press add and watch it land in the bag.</p>
<ul className="mt-6 grid gap-3 sm:grid-cols-3 sm:gap-4">
{products.map((p, i) => (
<motion.li
key={p.id}
initial={reduce ? false : { opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 240, damping: 22, delay: reduce ? 0 : 0.06 * i }}
className="flex gap-3 border-2 border-black bg-white p-3 sm:block"
>
<div className={`flex w-24 shrink-0 items-center justify-center border-2 border-black py-3 sm:h-44 sm:w-full ${p.tile}`}>
<div ref={(el) => { art.current[p.id] = el; }} className="h-20 w-14 sm:h-32 sm:w-20">
<Bottle juice={p.juice} />
</div>
</div>
<div className="flex min-w-0 flex-1 flex-col sm:mt-3">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-black uppercase text-black">{p.name}</h3>
<p className="text-base font-black tabular-nums text-black">{money(p.price)}</p>
</div>
<p className="text-sm font-medium text-neutral-700">{p.note}</p>
<button
type="button"
onClick={() => {
setAuto(false);
add(p.id);
}}
aria-label={`Add ${p.name} to bag`}
className={`mt-auto flex h-11 w-full items-center justify-center gap-2 border-2 border-black text-sm font-black uppercase transition-[transform,box-shadow,background-color] duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 motion-reduce:transition-none sm:mt-3 ${
pressed === p.id ? "translate-x-[3px] translate-y-[3px] bg-yellow-300 text-black shadow-none" : "bg-black text-white shadow-[3px_3px_0_0_#facc15] hover:bg-neutral-800 active:translate-x-[3px] active:translate-y-[3px] active:shadow-none"
}`}
>
{pressed === p.id ? (
<>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square" aria-hidden="true">
<motion.path d="M4 10.5l4 4 8-9" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, delay: 0.1 }} />
</svg>
Added
</>
) : (
<>
<span aria-hidden="true" className="text-lg leading-none">+</span>
Add
</>
)}
</button>
</div>
</motion.li>
))}
</ul>
</div>
<aside aria-label="Your bag" className="flex flex-col border-t-2 border-black bg-neutral-50 p-4 sm:p-6 lg:border-l-2 lg:border-t-0">
<p className="flex items-center justify-between text-sm font-black uppercase text-black">
<span>Your bag</span>
<span className="tabular-nums" aria-live="polite">
{count} {count === 1 ? "bottle" : "bottles"}
</span>
</p>
<ul className="mt-3">
<AnimatePresence initial={false}>
{bag.map((l) => {
const p = products.find((x) => x.id === l.id)!;
return (
<motion.li
key={l.id}
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="overflow-hidden"
>
<div className="flex items-center gap-3 border-b-2 border-black/10 py-2">
<div className={`h-12 w-10 shrink-0 border-2 border-black p-1 ${p.tile}`}>
<Bottle juice={p.juice} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-black uppercase text-black">{p.name}</p>
<p className="text-sm font-medium tabular-nums text-neutral-700">{money(p.price * l.qty)}</p>
</div>
<motion.span
key={l.qty}
initial={reduce ? false : { scale: 1.7, rotate: -12 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
className="flex h-7 min-w-[28px] items-center justify-center bg-yellow-300 px-1 text-sm font-black tabular-nums text-black"
>
×{l.qty}
</motion.span>
<button
type="button"
onClick={() => {
setAuto(false);
setBag((b) => b.filter((x) => x.id !== l.id));
}}
aria-label={`Remove ${p.name}`}
className="flex h-10 w-10 items-center justify-center text-neutral-700 hover:text-black focus:outline-none focus-visible:ring-2 focus-visible:ring-black"
>
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="square" aria-hidden="true">
<path d="M5 5l10 10M15 5L5 15" />
</svg>
</button>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
{bag.length === 0 && <p className="border-2 border-dashed border-black/40 px-3 py-6 text-center text-sm font-medium text-neutral-700">Nothing here yet. Add a bottle.</p>}
<div className="mt-auto pt-5">
<p className="text-sm font-bold text-black">{left > 0 ? `${money(left)} away from free delivery` : "Free delivery unlocked"}</p>
<div className="mt-2 h-3 border-2 border-black bg-white">
<motion.div
className="h-full w-full origin-left bg-black"
initial={false}
animate={{ scaleX: Math.min(1, total / FREE_DELIVERY) }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 170, damping: 16 }}
/>
</div>
<p className="mt-4 flex items-baseline justify-between text-black">
<span className="text-sm font-black uppercase">Subtotal</span>
<span className="text-2xl font-black tabular-nums">{money(total)}</span>
</p>
<button type="button" className="mt-3 h-12 w-full border-2 border-black bg-yellow-300 text-sm font-black uppercase text-black shadow-[4px_4px_0_0_#000] transition-[transform,box-shadow] duration-150 hover:bg-yellow-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 active:translate-x-[4px] active:translate-y-[4px] active:shadow-none motion-reduce:transition-none">
Checkout
</button>
</div>
</aside>
</div>
{flyers.map((f) => (
<motion.div
key={f.key}
aria-hidden="true"
className="pointer-events-none absolute z-20"
style={{ left: f.x, top: f.y, width: f.w, height: f.h }}
initial={{ x: 0, y: 0, scale: 1, rotate: 0, opacity: 1 }}
animate={{ x: f.dx, y: [0, Math.min(f.dy, 0) - 26, f.dy], scale: [1, 0.6, 0.22], rotate: [0, -22, 28], opacity: [1, 1, 0.7] }}
transition={{
duration: 0.78,
x: { duration: 0.78, ease: [0.5, 0, 0.9, 0.6] },
y: { duration: 0.78, times: [0, 0.7, 1], ease: ["easeOut", "easeIn"] },
scale: { duration: 0.78, times: [0, 0.7, 1] },
rotate: { duration: 0.78, times: [0, 0.7, 1] },
opacity: { duration: 0.78, times: [0, 0.8, 1] },
}}
onAnimationComplete={() => {
setFlyers((all) => all.filter((x) => x.key !== f.key));
land(f.id);
}}
>
<Bottle juice={products.find((p) => p.id === f.id)?.juice ?? "#000"} />
</motion.div>
))}
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/add-to-cart-with-item-flying-into-cart-and-count-bump.json
On press the component measures the product picture and the bag button with `getBoundingClientRect`, relative to the surrounding frame. It renders a copy of the picture at the start position and animates `x` with a slow start while `y` rises quickly and then dips, which together draw an arc. The copy shrinks and tilts on the way.
On landing. The flyer calls `land` from `onAnimationComplete`, which updates the bag state and starts the kick on the button, so the number changes at the moment the item arrives.
The count is rendered with its value as the `key` inside `AnimatePresence`. The old number slides up and out and the new one comes in from below, clipped by the black badge.
Yes. Replace `Bottle` with an `img` in both the card and the flyer. The flyer copies the size of the element it starts from, so any picture works.
Give the header button the `cart` ref and measure against the viewport instead of the frame, then render the flyer in a fixed layer. The arc code does not change.
Related: Cart Drawer with Quantity Steppers, Add to Bag Button with Morphing Letter Label, Product Card with Price, Rating, Colour Swatches and Add to Cart.
More ecommerce or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.