All patterns

Cart Item Remove

A removed line collapses its own height and gap while the survivors travel up and the total falls.

commercecalmminimalinteraction · finite · intermediate · ~0.5s
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.

363 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Cart Item Remove
 *
 * A removed line collapses its own height and takes the gap with it, the
 * lines beneath travel up into the space, and the total rolls down to
 * what is left — so the money and the list agree at every frame.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the bag reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `items`, `currency`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CartLine = {
  id: string;
  name: string;
  /** Small qualifier — size, colorway, quantity. */
  detail: string;
  /** Unit price in minor-unit-free numbers; formatting is applied below. */
  amount: number;
  /** Optional real photo for this line. */
  imageSrc?: string;
  /** Gradient used for the stand-in tile when there is no photo. */
  finish?: string;
};

export type CartItemRemoveProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Lines in the bag. */
  items?: CartLine[];
  /** Currency symbol placed before the amount. */
  currency?: string;
  /** Line shown once the bag is empty. */
  emptyLabel?: string;
  /** Fires with the removed line. */
  onRemove?: (item: CartLine) => void;
};

type VariantConfig = {
  /** How long a line takes to collapse out of the list. */
  collapseSeconds: number;
  /** Spring the surviving lines ride up on. */
  settle: { type: "spring"; stiffness: number; damping: number };
  /** Spring the total rolls on. */
  roll: { type: "spring"; stiffness: number; damping: number };
};

// Removing something is not a celebration: the gap closes and the money
// updates, and nothing bounces on the way. Damping ratios (damping /
// 2√stiffness) sit at or above 0.86 throughout, and the collapse itself
// is an eased tween because a closing gap that springs looks like the
// list is breathing.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a cut. For a bag being edited heavily.
  subtle: {
    collapseSeconds: 0.18,
    settle: { type: "spring", stiffness: 620, damping: 48 },
    roll: { type: "spring", stiffness: 620, damping: 48 },
  },
  // You can see which line left and where the gap closed. All-purpose.
  default: {
    collapseSeconds: 0.28,
    settle: { type: "spring", stiffness: 440, damping: 38 },
    roll: { type: "spring", stiffness: 440, damping: 38 },
  },
  // A longer close for a bag with only a few, expensive lines in it.
  playful: {
    collapseSeconds: 0.38,
    settle: { type: "spring", stiffness: 300, damping: 31 },
    roll: { type: "spring", stiffness: 320, damping: 33 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  Product finishes stay literal — they stand in for photos. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DEFAULT_ITEMS: CartLine[] = [
  {
    id: "holdall",
    name: "Waxed cotton holdall",
    detail: "Olive · 1",
    amount: 212,
    finish: "linear-gradient(146deg, #E7EADA 0%, #A9B48D 52%, #6E7A52 100%)",
  },
  {
    id: "pourover",
    name: "Stoneware pour-over",
    detail: "Matte oat · 1",
    amount: 74,
    finish: "linear-gradient(146deg, #F3E7DC 0%, #DFC3A8 52%, #BE9375 100%)",
  },
  {
    id: "notebook",
    name: "Linen notebook",
    detail: "Slate · 2",
    amount: 38,
    finish: "linear-gradient(146deg, #E4E8EE 0%, #A3ADBC 52%, #5B6472 100%)",
  },
];

const format = (currency: string, value: number) =>
  `${currency}${value.toFixed(2)}`;

export default function CartItemRemove({
  variant = "default",
  items = DEFAULT_ITEMS,
  currency = "$",
  emptyLabel = "Your bag is empty",
  onRemove,
}: CartItemRemoveProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [lines, setLines] = useState<CartLine[]>(items);

  const remove = (item: CartLine) => {
    setLines((current) => current.filter((line) => line.id !== item.id));
    onRemove?.(item);
  };

  const total = lines.reduce((sum, line) => sum + line.amount, 0);
  const formatted = format(currency, total);
  const empty = lines.length === 0;

  return (
    <div style={{ width: 282, fontSize: 13 }}>
      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <span
          style={{
            fontSize: 11.5,
            fontWeight: 600,
            opacity: 0.55,
            letterSpacing: 0.2,
            textTransform: "uppercase",
          }}
        >
          Your bag
        </span>
        <span style={{ marginLeft: "auto", fontSize: 11.5, opacity: 0.5 }}>
          {lines.length} {lines.length === 1 ? "line" : "lines"}
        </span>
      </div>

      {/* The list keeps a floor under it so the panel cannot collapse to
          nothing on the last removal. */}
      <div style={{ position: "relative", marginTop: 10, minHeight: 70 }}>
        <AnimatePresence initial={false}>
          {lines.map((line) => (
            // The wrapper owns the height and the gap, so a leaving line
            // takes its own spacing with it and the list closes flush.
            // layout="position" moves the survivors without stretching
            // them — a row that grows on the way up loses its identity.
            <motion.div
              key={line.id}
              layout={reduceMotion ? false : "position"}
              initial={false}
              exit={{
                height: 0,
                marginBottom: 0,
                opacity: 0,
                transition: reduceMotion
                  ? { duration: 0 }
                  : {
                      height: {
                        duration: cfg.collapseSeconds,
                        ease: [0.3, 0, 0.2, 1],
                      },
                      marginBottom: {
                        duration: cfg.collapseSeconds,
                        ease: [0.3, 0, 0.2, 1],
                      },
                      opacity: {
                        duration: cfg.collapseSeconds * 0.55,
                        ease: "easeIn",
                      },
                    },
              }}
              transition={reduceMotion ? { duration: 0 } : cfg.settle}
              style={{ overflow: "hidden", marginBottom: 8 }}
            >
              <div
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 11,
                  padding: 10,
                  borderRadius: 12,
                  background: tone(5),
                  border: `1px solid ${tone(10)}`,
                }}
              >
                <span
                  aria-hidden
                  style={{
                    width: 42,
                    height: 42,
                    flexShrink: 0,
                    borderRadius: 9,
                    overflow: "hidden",
                    display: "block",
                    background: line.finish ?? tone(12),
                  }}
                >
                  {line.imageSrc ? (
                    <img
                      src={line.imageSrc}
                      alt=""
                      style={{
                        width: "100%",
                        height: "100%",
                        objectFit: "cover",
                        display: "block",
                      }}
                    />
                  ) : null}
                </span>

                <span style={{ display: "grid", gap: 2, minWidth: 0, flex: 1 }}>
                  <span
                    style={{
                      fontSize: 12.5,
                      fontWeight: 600,
                      whiteSpace: "nowrap",
                      overflow: "hidden",
                      textOverflow: "ellipsis",
                    }}
                  >
                    {line.name}
                  </span>
                  <span style={{ fontSize: 11, opacity: 0.5 }}>
                    {line.detail}
                  </span>
                </span>

                <span
                  style={{
                    fontSize: 12.5,
                    fontWeight: 600,
                    fontVariantNumeric: "tabular-nums",
                  }}
                >
                  {format(currency, line.amount)}
                </span>

                <button
                  type="button"
                  onClick={() => remove(line)}
                  aria-label={`Remove ${line.name}`}
                  style={{
                    width: 26,
                    height: 26,
                    flexShrink: 0,
                    display: "grid",
                    placeItems: "center",
                    padding: 0,
                    border: 0,
                    borderRadius: 999,
                    background: tone(8),
                    color: "inherit",
                    cursor: "pointer",
                  }}
                >
                  <svg
                    width="10"
                    height="10"
                    viewBox="0 0 12 12"
                    fill="none"
                    aria-hidden
                  >
                    <path
                      d="M2.6 2.6 9.4 9.4M9.4 2.6 2.6 9.4"
                      stroke="currentColor"
                      strokeWidth="1.7"
                      strokeLinecap="round"
                    />
                  </svg>
                </button>
              </div>
            </motion.div>
          ))}
        </AnimatePresence>

        {/* The empty line waits behind the list rather than replacing it,
            so the last collapse closes onto a sentence that is already
            there instead of onto nothing. */}
        <motion.div
          initial={false}
          animate={{ opacity: empty ? 0.6 : 0 }}
          transition={{
            duration: 0.24,
            ease: "easeOut",
            delay: empty && !reduceMotion ? cfg.collapseSeconds * 0.6 : 0,
          }}
          aria-hidden={!empty}
          style={{
            position: "absolute",
            left: 0,
            right: 0,
            top: 0,
            height: 62,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            borderRadius: 12,
            border: `1px dashed ${tone(16)}`,
            fontSize: 12.5,
            pointerEvents: empty ? "auto" : "none",
          }}
        >
          {emptyLabel}
        </motion.div>
      </div>

      <div style={{ height: 1, background: tone(12), margin: "5px 0 12px" }} />

      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <span style={{ fontSize: 13, fontWeight: 600 }}>Subtotal</span>
        {/* The total rolls downward as lines leave: it travels and
            crossfades, and holds one type size the whole way. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            height: 24,
            overflow: "hidden",
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={formatted}
              initial={{ y: reduceMotion ? 0 : -24, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: reduceMotion ? 0 : 24, opacity: 0 }}
              transition={{
                y: reduceMotion ? { duration: 0 } : cfg.roll,
                opacity: { duration: 0.18, ease: "easeOut" },
              }}
              style={{
                gridArea: "1 / 1",
                fontSize: 19,
                fontWeight: 650,
                lineHeight: "24px",
                letterSpacing: -0.2,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {formatted}
            </motion.span>
          </AnimatePresence>
        </span>
      </div>
    </div>
  );
}

About this pattern

Taking something out of a bag is an edit, not an event. The leaving line collapses its height and its own bottom gap together, so the list closes flush instead of leaving a seam; the lines beneath travel up on a position-only layout animation, which moves them without stretching the type inside; and the subtotal rolls downward at a constant size. The collapse is an eased tween rather than a spring — a closing gap that springs makes the list look like it is breathing. An empty line waits behind the list so the last removal closes onto a sentence rather than onto nothing.

Removing a line from a bagCart drawer editingSaved-items list managementOrder review before paying

Where it shows up

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

  • Your bag
    Ridgeline GT — Bone, US 91+$132.00
    Merino crew sock, 2-pack2+$24.00
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Checkout
    Cart

    A removed bag line closes its space and the summary restates itself underneath.

Related patterns