Add to Cart Fly
The product tile arcs from the card into the cart glyph, and the badge ticks over on arrival.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Add to Cart Fly
*
* The product tile leaves the card on an arc, shrinks into the cart
* glyph, and only then does the badge tick over — the count changes
* because something arrived, not because a button was pressed.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the card reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `imageSrc`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AddToCartFlyProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Real photo for the tile. Omitted, the tile draws its own stand-in. */
imageSrc?: string;
/** Product title on the card. */
productName?: string;
/** Small line under the title — size, colorway, whatever qualifies it. */
productMeta?: string;
/** Formatted price. Formatting stays yours. */
price?: string;
/** Label of the buy control. */
actionLabel?: string;
/** Items already in the cart. */
initialCount?: number;
/** Accent for the buy control and the badge. */
accent?: string;
/** Fires when a flight lands, with the new count. */
onAdd?: (count: number) => void;
};
type Flight = {
id: number;
left: number;
top: number;
size: number;
dx: number;
dy: number;
};
type VariantConfig = {
/** Length of the trip. */
seconds: number;
/** How far above the straight line the arc peaks, in px. */
lift: number;
/** Scale the tile has shrunk to by the time it reaches the glyph. */
landScale: number;
/** Spring the badge settles on when the count changes. */
badge: { type: "spring"; stiffness: number; damping: number };
};
// The tile travels and shrinks; nothing here wobbles. Badge damping
// ratios (damping / 2√stiffness) stay at or above 0.81, so a shopper
// adding five things in a row never sees the count jitter.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short hop with barely any arc. For dense grids where adds are constant.
subtle: {
seconds: 0.42,
lift: 22,
landScale: 0.3,
badge: { type: "spring", stiffness: 520, damping: 46 },
},
// A readable arc — you can follow the item with your eye. All-purpose.
default: {
seconds: 0.56,
lift: 46,
landScale: 0.22,
badge: { type: "spring", stiffness: 420, damping: 36 },
},
// A high, slow throw for a single hero product page.
playful: {
seconds: 0.7,
lift: 76,
landScale: 0.16,
badge: { type: "spring", stiffness: 340, damping: 30 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one.
* The accent and the photo stand-in stay literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Stand-in for the product shot: a gradient plus a drawn silhouette, so
* the file stays one copyable unit with no asset beside it. */
function ProductArt({ imageSrc }: { imageSrc?: string }) {
if (imageSrc) {
return (
<img
src={imageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
);
}
return (
<div
aria-hidden
style={{
width: "100%",
height: "100%",
display: "grid",
placeItems: "center",
background:
"linear-gradient(148deg, #D7DEFF 0%, #A9B6FB 46%, #7E8CF3 100%)",
}}
>
<svg viewBox="0 0 40 40" width="54%" height="54%" fill="none">
<path
d="M9.5 13.5h21l2 19.2a2 2 0 0 1-2 2.2H9.5a2 2 0 0 1-2-2.2l2-19.2Z"
stroke="#FFFFFF"
strokeOpacity="0.9"
strokeWidth="2"
strokeLinejoin="round"
/>
<path
d="M14.8 17V12a5.2 5.2 0 0 1 10.4 0v5"
stroke="#FFFFFF"
strokeOpacity="0.9"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
);
}
export default function AddToCartFly({
variant = "default",
imageSrc,
productName = "Canvas weekender",
productMeta = "Sand · 38 L",
price = "$148.00",
actionLabel = "Add to bag",
initialCount = 0,
accent = "#7C7CF0",
onAdd,
}: AddToCartFlyProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const wrapRef = useRef<HTMLDivElement>(null);
const tileRef = useRef<HTMLDivElement>(null);
const cartRef = useRef<HTMLButtonElement>(null);
const flightId = useRef(0);
// The count lives in a ref as well so a landing never has to read it
// out of a stale closure or mutate state from inside an updater.
const countRef = useRef(initialCount);
const [count, setCount] = useState(initialCount);
const [flights, setFlights] = useState<Flight[]>([]);
const land = (id: number) => {
setFlights((current) => current.filter((flight) => flight.id !== id));
countRef.current += 1;
setCount(countRef.current);
onAdd?.(countRef.current);
};
const handleAdd = () => {
const wrap = wrapRef.current?.getBoundingClientRect();
const from = tileRef.current?.getBoundingClientRect();
const to = cartRef.current?.getBoundingClientRect();
// Reduced motion: the item still arrives, it just does not travel to
// get there. The count is the information; the arc is the decoration.
if (reduceMotion || !wrap || !from || !to) {
countRef.current += 1;
setCount(countRef.current);
onAdd?.(countRef.current);
return;
}
flightId.current += 1;
setFlights((current) => [
...current,
{
id: flightId.current,
left: from.left - wrap.left,
top: from.top - wrap.top,
size: from.width,
// Centre-to-centre, so the tile lands on the glyph whatever the
// two elements happen to measure in the host layout.
dx: to.left + to.width / 2 - (from.left + from.width / 2),
dy: to.top + to.height / 2 - (from.top + from.height / 2),
},
]);
};
return (
<div
ref={wrapRef}
style={{
position: "relative",
width: 272,
display: "flex",
flexDirection: "column",
gap: 14,
fontSize: 13,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontSize: 12, fontWeight: 600, opacity: 0.55 }}>
Bags & luggage
</span>
<button
ref={cartRef}
type="button"
aria-label={`Cart, ${count} item${count === 1 ? "" : "s"}`}
style={{
position: "relative",
marginLeft: "auto",
width: 36,
height: 36,
display: "grid",
placeItems: "center",
borderRadius: 10,
border: `1px solid ${tone(12)}`,
background: tone(5),
color: "inherit",
cursor: "pointer",
}}
>
<svg width="17" height="17" viewBox="0 0 20 20" fill="none" aria-hidden>
<path
d="M2.6 3.2h2.1l2 9.6h8.5l1.8-6.9H6.1"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="8.4" cy="16.1" r="1.3" fill="currentColor" />
<circle cx="14.4" cy="16.1" r="1.3" fill="currentColor" />
</svg>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: count > 0 ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
top: -6,
right: -7,
display: "grid",
width: 20,
height: 20,
placeItems: "center",
}}
>
{/* Only the disc settles. Remounting it on every count change
gives exactly one soft landing and nothing after it. */}
<motion.span
key={`disc-${count}`}
initial={{ scale: count <= 1 ? 0.4 : 0.84 }}
animate={{ scale: 1 }}
transition={reduceMotion ? { duration: 0 } : cfg.badge}
style={{
gridArea: "1 / 1",
width: 20,
height: 20,
borderRadius: 999,
background: accent,
}}
/>
{/* The number rolls: it travels and crossfades, never scales.
A digit that grows and shrinks is unreadable mid-change. */}
<span
style={{
gridArea: "1 / 1",
display: "grid",
width: 20,
height: 13,
overflow: "hidden",
placeItems: "center",
}}
>
<AnimatePresence initial={false}>
<motion.span
key={count}
initial={{ y: reduceMotion ? 0 : -13, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: reduceMotion ? 0 : 13, opacity: 0 }}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{
gridArea: "1 / 1",
fontSize: 11,
fontWeight: 700,
lineHeight: "13px",
color: "#FFFFFF",
fontVariantNumeric: "tabular-nums",
}}
>
{count}
</motion.span>
</AnimatePresence>
</span>
</motion.span>
</button>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: 12,
borderRadius: 14,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
<div
ref={tileRef}
style={{
width: 62,
height: 62,
flexShrink: 0,
borderRadius: 12,
overflow: "hidden",
}}
>
<ProductArt imageSrc={imageSrc} />
</div>
<div style={{ display: "grid", gap: 3, minWidth: 0, flex: 1 }}>
<span style={{ fontSize: 13, fontWeight: 600, lineHeight: 1.25 }}>
{productName}
</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{productMeta}</span>
<span
style={{
fontSize: 12.5,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
}}
>
{price}
</span>
</div>
</div>
<button
type="button"
onClick={handleAdd}
style={{
height: 40,
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#FFFFFF",
background: accent,
border: 0,
borderRadius: 11,
cursor: "pointer",
}}
>
{actionLabel}
</button>
<span
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{count > 0 ? `${count} in cart` : ""}
</span>
{/* The flights sit above the card in their own layer so the arc can
cross the whole component without the card clipping it. */}
{flights.map((flight) => (
<motion.div
key={flight.id}
aria-hidden
initial={{ x: 0, y: 0, scale: 1, opacity: 1 }}
animate={{
x: [0, flight.dx * 0.52, flight.dx],
y: [0, flight.dy * 0.52 - cfg.lift, flight.dy],
scale: [1, 0.6, cfg.landScale],
opacity: [1, 1, 0.2],
}}
transition={{
duration: cfg.seconds,
times: [0, 0.52, 1],
// Out of the card quickly, into the glyph decisively — a
// single ease across the whole trip reads as floating.
ease: ["easeOut", "easeIn"],
}}
onAnimationComplete={() => land(flight.id)}
style={{
position: "absolute",
left: flight.left,
top: flight.top,
width: flight.size,
height: flight.size,
borderRadius: 12,
overflow: "hidden",
pointerEvents: "none",
zIndex: 2,
}}
>
<ProductArt imageSrc={imageSrc} />
</motion.div>
))}
</div>
);
}About this pattern
The buy moment, made legible. Pressing the buy control sends a copy of the product tile along an arc toward the cart glyph, shrinking as it goes, and the badge only changes once the tile has landed. Tying the count to an arrival rather than to the press answers the question shoppers actually ask — where did the thing I just bought go — which matters most in grids where several items are added in a row. The tile travels and shrinks; the number itself rolls and crossfades without ever changing size.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Product page
Adding from a product page bumps the cart badge as the item registers.
Related patterns
- Cart Badge CountThe cart badge rolls each changed digit in the direction of the change and settles once.
- Coupon ApplyA valid code folds into a chip, a discount line opens in the summary, and the total falls.
- Wishlist Heart FillThe heart fills from its own center on one soft settle, and drains back out on a plain ease.
