All patterns

Empty Cart

The cart mark drops and settles once, then recently viewed items slide in as the way back to shopping.

empty-statesfriendlycalmautomatic · finite · starter · ~1.0s
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.

265 lines · react + motion only
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Empty Cart
 *
 * An empty cart is a shopping session that stalled, so the block spends
 * its motion on the way back in. The cart mark drops onto its line and
 * settles once, the sentence follows, and the things the shopper was
 * just looking at slide in from the right — the direction a carousel
 * would come from, which is the point: there is more where that came
 * from.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BrowseItem = {
  name: string;
  price: string;
  /** Real product photograph; omit for the gradient stand-in. */
  imageSrc?: string;
  /** Stands in for product photography, so it stays a literal color. */
  swatch: string;
};

export type EmptyCartBrowseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Headline of the empty cart. */
  title?: string;
  /** One supporting line. */
  message?: string;
  /** Heading over the row of items. */
  sectionLabel?: string;
  /** Items offered as the way back in. Three fits a phone width. */
  items?: BrowseItem[];
  /** Fires with the item that was picked. */
  onSelect?: (item: BrowseItem) => void;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** px the cart mark descends. */
  drop: number;
  /** px each item card travels in from the right. */
  slide: number;
  /** Seconds between one card arriving and the next. */
  stagger: number;
  fadeSeconds: number;
  cartSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the cart glyph is the only sprung element, above 0.88
// damping ratio (damping / 2√stiffness) in every variant, so it lands
// with one settle. Product cards carry prices, so they travel on tweens
// and never scale — a price that bounces looks like a price that moved.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For a cart drawer that empties and refills during one session.
  subtle: {
    drop: 6,
    slide: 8,
    stagger: 0.04,
    fadeSeconds: 0.24,
    cartSpring: { type: "spring", stiffness: 460, damping: 44 },
  },
  // The all-purpose setting: a clear drop, a clear invitation.
  default: {
    drop: 10,
    slide: 14,
    stagger: 0.065,
    fadeSeconds: 0.3,
    cartSpring: { type: "spring", stiffness: 370, damping: 37 },
  },
  // A longer arrival, for a full cart page where this is the only
  // content on screen.
  playful: {
    drop: 15,
    slide: 20,
    stagger: 0.09,
    fadeSeconds: 0.34,
    cartSpring: { type: "spring", stiffness: 300, damping: 31 },
  },
};

const ITEMS: BrowseItem[] = [
  {
    name: "Desk lamp",
    price: "$64",
    swatch: "linear-gradient(140deg, #E0A458 0%, #B9762F 100%)",
  },
  {
    name: "Mug set",
    price: "$42",
    swatch: "linear-gradient(140deg, #4FA3A5 0%, #2F6E70 100%)",
  },
  {
    name: "Storage box",
    price: "$38",
    swatch: "linear-gradient(140deg, #7C7CF0 0%, #4B4BB8 100%)",
  },
];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps the mark, the rule and the cards correct on light
 *  and dark pages. Product swatches stay literal — they stand in for
 *  photography. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function EmptyCartBrowse({
  variant = "default",
  title = "Your cart is empty",
  message = "Anything you add will wait for you here.",
  sectionLabel = "Recently viewed",
  items = ITEMS,
  onSelect,
  width = 320,
}: EmptyCartBrowseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const drop = reduceMotion ? 0 : cfg.drop;
  const slide = reduceMotion ? 0 : cfg.slide;
  const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };

  return (
    <div style={{ width, boxSizing: "border-box", padding: "24px 16px 18px" }}>
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          textAlign: "center",
        }}
      >
        <motion.div
          initial={{ opacity: 0, y: -drop }}
          animate={{ opacity: 1, y: 0 }}
          transition={
            reduceMotion
              ? { duration: 0.2, ease: "easeOut" }
              : { ...cfg.cartSpring, opacity: fade }
          }
          style={{ lineHeight: 0, marginBottom: 12 }}
        >
          <CartMark />
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: drop * 0.6 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ ...fade, delay: 0.08 }}
          style={{ fontSize: 15, fontWeight: 640 }}
        >
          {title}
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: drop * 0.6 }}
          animate={{ opacity: 0.55, y: 0 }}
          transition={{ ...fade, delay: 0.14 }}
          style={{ fontSize: 12.5, marginTop: 5 }}
        >
          {message}
        </motion.div>
      </div>

      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 0.45 }}
        transition={{ ...fade, delay: 0.22 }}
        style={{
          fontSize: 11,
          fontWeight: 600,
          letterSpacing: 0.5,
          textTransform: "uppercase",
          marginTop: 20,
          marginBottom: 10,
          paddingTop: 14,
          borderTop: `1px solid ${tone(9)}`,
        }}
      >
        {sectionLabel}
      </motion.div>

      <div style={{ display: "flex", gap: 9 }}>
        {items.map((item, index) => (
          <motion.button
            key={item.name}
            type="button"
            onClick={() => onSelect?.(item)}
            initial={{ opacity: 0, x: slide }}
            animate={{ opacity: 1, x: 0 }}
            transition={{ ...fade, delay: 0.26 + index * cfg.stagger }}
            style={{
              font: "inherit",
              flex: 1,
              minWidth: 0,
              display: "flex",
              flexDirection: "column",
              alignItems: "stretch",
              gap: 7,
              padding: 8,
              borderRadius: 12,
              textAlign: "left",
              color: "inherit",
              background: tone(5),
              border: `1px solid ${tone(11)}`,
              cursor: "pointer",
            }}
          >
            <span
              aria-hidden
              style={{
                display: "block",
                height: 44,
                borderRadius: 8,
                background: item.imageSrc
                  ? `url(${item.imageSrc}) center / cover, ${item.swatch}`
                  : item.swatch,
              }}
            />
            <span
              style={{
                fontSize: 11.5,
                fontWeight: 600,
                whiteSpace: "nowrap",
                overflow: "hidden",
                textOverflow: "ellipsis",
              }}
            >
              {item.name}
            </span>
            <span style={{ fontSize: 11, opacity: 0.55, marginTop: -4 }}>
              {item.price}
            </span>
          </motion.button>
        ))}
      </div>
    </div>
  );
}

/** Line art authored inline: a cart, stroked in `currentColor` at low
 *  opacity so it needs no asset and inherits the page theme. */
function CartMark() {
  return (
    <svg width="52" height="46" viewBox="0 0 52 46" fill="none" aria-hidden>
      <g
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.34"
      >
        <path d="M6 7h5.5l5 21.5h21L42 13H14" />
        <circle cx="19.5" cy="36" r="2.8" />
        <circle cx="35.5" cy="36" r="2.8" />
      </g>
    </svg>
  );
}

About this pattern

An empty cart is a stalled session rather than a missing feature, so the block spends its motion on the return route. The cart glyph descends and settles a single time, the sentence follows it, and the items the shopper was looking at a moment ago slide in from the right in a short cascade — the direction goods arrive from in a carousel, which is exactly the suggestion being made. Prices travel on tweens and never scale, because a number that bounces reads as a number that changed. Reduced motion keeps the order and drops the travel.

Empty cart drawerCheckout with no itemsWishlist with nothing savedOrder history before a first purchase

Where it shows up

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

  • Your bag
    Your bag is emptyRecently viewed appears below.
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Checkout
    Cart

    An empty bag pairs a light mark with a strip of recently viewed products underneath.

Related patterns