All patterns

Add to Cart Fly

The product tile arcs from the card into the cart glyph, and the badge ticks over on arrival.

commerceenergeticfriendlyinteraction · finite · intermediate · ~0.8s
Interactive · click to play
Variant

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.

425 lines · react + motion only
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 &amp; 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.

Buy control on a product pageQuick add from a product gridMoving a saved item into the bagReorder from purchase history

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • NewMenWomenSale
    Men's trail shoeRidgeline GT$132Bone
    88.599.510
    Add to bag
    Free delivery and returns
    Product details
    Product page

    Adding from a product page bumps the cart badge as the item registers.

Related patterns