All patterns

Cart Drawer Open

The cart panel travels in from the edge and its line items arrive a beat behind it.

commercepremiumminimalinteraction · finite · intermediate · ~0.7s
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.

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

/**
 * Vibary · Cart Drawer Open
 *
 * The cart travels in from the edge and its contents arrive a beat
 * behind it — panel first, then line items a hair apart, then the
 * summary. The delay is the whole trick: contents that ride in with the
 * panel look painted on, contents that follow it look like they were
 * always inside.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The store behind is mixed from the inherited text color and the panel
 * uses the CSS system colors, so both land correctly on a light page and
 * on a dark one. Product thumbnails are CSS gradients standing in for
 * photography — swap them for an <img> and the motion is unchanged.
 * Works with zero props; tune via `variant`, `side`, `items`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CartDrawerItem = {
  id: string;
  name: string;
  detail: string;
  price: string;
  /** Real product photograph; omit for the gradient stand-in. */
  imageSrc?: string;
  /** Gradient shown while there is no photo. */
  art?: string;
};

export type CartDrawerOpenProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Lines shown in the cart; each may carry a photo via `imageSrc`. */
  items?: readonly CartDrawerItem[];
  /** Edge the cart travels in from. */
  side?: "right" | "left";
  /** Open on first render. */
  defaultOpen?: boolean;
  /** Notified whenever the cart opens or closes. */
  onOpenChange?: (open: boolean) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds before the first line item follows the panel in. */
  contentDelay: number;
  /** Seconds between line items. */
  stagger: number;
  /** How far each line item travels, in pixels. */
  travel: number;
  exitDuration: number;
};

// Quality rule: the panel is the largest surface on screen during this
// move, and a large surface makes overshoot look like a mistake — every
// spring is at or above a 0.8 damping ratio, so it arrives once and
// stops. Line items translate and fade; prices never scale.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Lands flat, contents almost with it. For a cart opened on every add.
  subtle: {
    spring: { type: "spring", stiffness: 500, damping: 42 },
    contentDelay: 0.05,
    stagger: 0.025,
    travel: 8,
    exitDuration: 0.18,
  },
  // One soft settle, contents a clear beat behind. All-purpose.
  default: {
    spring: { type: "spring", stiffness: 390, damping: 34 },
    contentDelay: 0.1,
    stagger: 0.045,
    travel: 14,
    exitDuration: 0.2,
  },
  // A longer entrance for a cart that is the whole point of the tap.
  playful: {
    spring: { type: "spring", stiffness: 330, damping: 31 },
    contentDelay: 0.14,
    stagger: 0.06,
    travel: 22,
    exitDuration: 0.22,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Thumbnails stand in for product photography, so they stay literal —
 *  a placeholder for an image, not a surface that follows the theme. */
export const DEFAULT_ITEMS: readonly CartDrawerItem[] = [
  {
    id: "carafe",
    name: "Cold brew carafe",
    detail: "1 L · Smoke",
    price: "$38.00",
    art: "linear-gradient(145deg, #6E8BFA, #9A6BF0)",
  },
  {
    id: "grinder",
    name: "Hand grinder",
    detail: "Steel burr",
    price: "$64.00",
    art: "linear-gradient(145deg, #F0A45C, #D9536B)",
  },
  {
    id: "filters",
    name: "Paper filters",
    detail: "Pack of 200",
    price: "$9.50",
    art: "linear-gradient(145deg, #3FB58A, #2E8FA8)",
  },
];

/** A photograph fills the thumb, layered over the gradient so it never
 *  goes blank while the file loads (or if it fails to). */
const thumbArt = (item: CartDrawerItem) =>
  item.imageSrc
    ? [`url(${item.imageSrc}) center / cover`, item.art]
        .filter(Boolean)
        .join(", ")
    : item.art;

const SUBTOTAL = "$111.50";

export default function CartDrawerOpen({
  variant = "default",
  side = "right",
  items = DEFAULT_ITEMS,
  defaultOpen = false,
  onOpenChange,
}: CartDrawerOpenProps) {
  const [open, setOpen] = useState(defaultOpen);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const setCart = (next: boolean) => {
    setOpen(next);
    onOpenChange?.(next);
  };

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setOpen(false);
      onOpenChange?.(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, onOpenChange]);

  const fromRight = side === "right";
  const offscreen = fromRight ? "100%" : "-100%";

  // Reduced motion: the cart still takes the foreground over a dimmed
  // store, it just stops travelling across the frame to get there, and
  // its contents arrive together instead of in sequence.
  const panelMotion = reduceMotion
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: { duration: 0.12 } },
        transition: { duration: 0.16, ease: "easeOut" as const },
      }
    : {
        initial: { x: offscreen },
        animate: { x: "0%" },
        exit: {
          x: offscreen,
          transition: { duration: cfg.exitDuration, ease: "easeIn" as const },
        },
        transition: cfg.spring,
      };

  const contentAt = (index: number) =>
    reduceMotion ? 0.08 : cfg.contentDelay + index * cfg.stagger;

  return (
    <div
      style={{
        position: "relative",
        width: 330,
        height: 388,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 16px 40px rgba(0,0,0,0.18)",
        // The cart and scrim are positioned against this box rather than
        // the viewport, so the pattern drops into a preview or an
        // embedded card. For an app-level cart, swap `absolute` for
        // `fixed` on the scrim and the panel.
        overflow: "hidden",
        fontFamily: "inherit",
      }}
    >
      <div
        style={{
          height: "100%",
          display: "flex",
          flexDirection: "column",
        }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "center",
            gap: 10,
            padding: "0 14px",
            height: 52,
            borderBottom: `1px solid ${tone(12)}`,
          }}
        >
          <span style={{ flex: 1, fontSize: 14, fontWeight: 650 }}>
            Roasters supply
          </span>
          <button
            type="button"
            onClick={() => setCart(true)}
            aria-expanded={open}
            style={{
              display: "flex",
              alignItems: "center",
              gap: 7,
              padding: "7px 11px",
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              borderRadius: 9,
              border: `1px solid ${tone(14)}`,
              background: tone(8),
              color: "inherit",
              cursor: "pointer",
            }}
          >
            <svg
              width="14"
              height="14"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.7"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
            >
              <path d="M2.5 3.5h2.2l1.9 9.2h8.4l1.6-6.4H6" />
              <circle cx="8" cy="16.4" r="1.3" />
              <circle cx="14.6" cy="16.4" r="1.3" />
            </svg>
            Cart · {items.length}
          </button>
        </div>

        <div style={{ flex: 1, padding: 12, display: "grid", gap: 10 }}>
          {items.map((item) => (
            <div
              key={item.id}
              style={{
                display: "flex",
                gap: 10,
                alignItems: "center",
                opacity: 0.55,
              }}
            >
              <span
                aria-hidden
                style={{
                  width: 44,
                  height: 44,
                  flexShrink: 0,
                  borderRadius: 10,
                  background: thumbArt(item),
                }}
              />
              <span style={{ flex: 1, minWidth: 0 }}>
                <span
                  style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
                >
                  {item.name}
                </span>
                <span style={{ display: "block", fontSize: 11, opacity: 0.6 }}>
                  {item.detail}
                </span>
              </span>
            </div>
          ))}
        </div>
      </div>

      {/* The scrim and the panel are siblings so AnimatePresence tracks
          both directly — inside a fragment it would see neither and skip
          the exit. */}
      <AnimatePresence>
        {open && (
          <motion.div
            key="scrim"
            aria-hidden
            onClick={() => setCart(false)}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: cfg.exitDuration } }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              // A scrim darkens in both themes — light or dark, the page
              // behind an overlay recedes — so this one stays literal.
              background: "rgba(0,0,0,0.42)",
              cursor: "pointer",
            }}
          />
        )}
        {open && (
          <motion.div
            key="cart"
            role="dialog"
            aria-modal="true"
            aria-label="Your cart"
            {...panelMotion}
            style={{
              position: "absolute",
              top: 0,
              bottom: 0,
              left: fromRight ? "auto" : 0,
              right: fromRight ? 0 : "auto",
              width: 252,
              display: "flex",
              flexDirection: "column",
              // The one surface here that cannot be translucent: it sits
              // on top of the scrim, and a see-through panel would read
              // as more scrim. `Canvas`/`CanvasText` are the CSS system
              // colors for page background and page text, so the cart
              // lands light in a light app and dark in a dark one.
              // Everything inside then mixes from `currentColor`.
              background: "Canvas",
              color: "CanvasText",
              borderLeft: fromRight ? `1px solid ${tone(14)}` : undefined,
              borderRight: fromRight ? undefined : `1px solid ${tone(14)}`,
              boxShadow: fromRight
                ? "-16px 0 38px rgba(0,0,0,0.3)"
                : "16px 0 38px rgba(0,0,0,0.3)",
            }}
          >
            <motion.div
              initial={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: 0.24,
                ease: "easeOut",
                delay: contentAt(0),
              }}
              style={{
                display: "flex",
                alignItems: "center",
                gap: 8,
                padding: "14px 14px 12px",
              }}
            >
              <span style={{ flex: 1, fontSize: 14, fontWeight: 660 }}>
                Your cart
              </span>
              <button
                type="button"
                onClick={() => setCart(false)}
                aria-label="Close cart"
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 26,
                  height: 26,
                  borderRadius: 8,
                  border: `1px solid ${tone(14)}`,
                  background: tone(7),
                  color: "inherit",
                  cursor: "pointer",
                }}
              >
                <svg
                  width="12"
                  height="12"
                  viewBox="0 0 20 20"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2"
                  strokeLinecap="round"
                  aria-hidden
                >
                  <path d="M5 5l10 10M15 5 5 15" />
                </svg>
              </button>
            </motion.div>

            <div
              style={{
                flex: 1,
                padding: "0 14px",
                display: "grid",
                gap: 12,
                alignContent: "start",
              }}
            >
              {items.map((item, index) => (
                // Each line follows the panel, not the line before it —
                // the stagger is a delay on a shared start, so a slow
                // frame cannot stretch the gaps into a queue.
                <motion.div
                  key={item.id}
                  initial={{
                    opacity: 0,
                    x: reduceMotion ? 0 : fromRight ? cfg.travel : -cfg.travel,
                  }}
                  animate={{ opacity: 1, x: 0 }}
                  transition={{
                    ...(reduceMotion
                      ? { duration: 0.2, ease: "easeOut" as const }
                      : cfg.spring),
                    delay: contentAt(index + 1),
                    opacity: {
                      duration: 0.24,
                      ease: "easeOut",
                      delay: contentAt(index + 1),
                    },
                  }}
                  style={{ display: "flex", gap: 10, alignItems: "center" }}
                >
                  <span
                    aria-hidden
                    style={{
                      width: 42,
                      height: 42,
                      flexShrink: 0,
                      borderRadius: 10,
                      background: thumbArt(item),
                    }}
                  />
                  <span style={{ flex: 1, minWidth: 0 }}>
                    <span
                      style={{
                        display: "block",
                        fontSize: 12.5,
                        fontWeight: 620,
                        whiteSpace: "nowrap",
                        overflow: "hidden",
                        textOverflow: "ellipsis",
                      }}
                    >
                      {item.name}
                    </span>
                    <span
                      style={{ display: "block", fontSize: 11, opacity: 0.55 }}
                    >
                      {item.detail}
                    </span>
                  </span>
                  <span
                    style={{
                      fontSize: 12.5,
                      fontWeight: 620,
                      fontVariantNumeric: "tabular-nums",
                    }}
                  >
                    {item.price}
                  </span>
                </motion.div>
              ))}
            </div>

            <motion.div
              initial={{ opacity: 0, y: reduceMotion ? 0 : 10 }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                ...(reduceMotion
                  ? { duration: 0.2, ease: "easeOut" as const }
                  : cfg.spring),
                delay: contentAt(items.length + 1),
                opacity: {
                  duration: 0.24,
                  ease: "easeOut",
                  delay: contentAt(items.length + 1),
                },
              }}
              style={{
                padding: 14,
                borderTop: `1px solid ${tone(12)}`,
                display: "grid",
                gap: 10,
              }}
            >
              <div
                style={{
                  display: "flex",
                  justifyContent: "space-between",
                  fontSize: 13,
                  fontWeight: 660,
                }}
              >
                <span>Subtotal</span>
                <span style={{ fontVariantNumeric: "tabular-nums" }}>
                  {SUBTOTAL}
                </span>
              </div>
              <button
                type="button"
                style={{
                  padding: "10px 14px",
                  fontSize: 13,
                  fontWeight: 650,
                  fontFamily: "inherit",
                  borderRadius: 10,
                  border: "none",
                  background: ACCENT,
                  color: "#FFFFFF",
                  cursor: "pointer",
                }}
              >
                Go to checkout
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

About this pattern

The panel that opens the moment something is added. The delay between the panel landing and its contents arriving is the whole trick: contents that ride in with the panel look painted onto it, contents that follow it look like they were always inside. Each line is delayed from one shared start rather than chained to the line before it, so a dropped frame cannot stretch the gaps into a queue, and the summary follows last. The panel is opaque over its scrim and springs at a damping ratio that lets it arrive once and stop.

Mini cart panelAdded-to-bag confirmationSide cart on a storefrontCheckout entry point

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

    Adding an item opens a side cart whose lines settle in just after the panel lands.

Related patterns