All patterns

Receipt Unroll

The payment clears and the receipt unrolls beneath it, line items settling in behind the torn edge.

commercepremiumfriendlyautomatic · finite · intermediate · ~1.4s
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.

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

/**
 * Vibary · Receipt Unroll
 *
 * The payment clears and the receipt unrolls beneath it: the paper grows
 * downward on an eased height tween, the torn edge travels with it, and
 * the line items settle in behind the growing edge.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The paper is mixed from the inherited text color, so it reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `items`, `total`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ReceiptItem = {
  id: string;
  name: string;
  /** Small qualifier — quantity, size, colorway. */
  detail: string;
  /** Formatted amount. Formatting stays yours. */
  amount: string;
};

export type ReceiptUnrollProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Who the money went to. */
  merchant?: string;
  /** Reference printed under the merchant. */
  reference?: string;
  /** Lines on the receipt. */
  items?: ReceiptItem[];
  /** Formatted total. */
  total?: string;
  /** How the payment was taken. */
  method?: string;
  /** Beat before the payment clears, in ms. */
  clearMs?: number;
  /** Fires once the paper has finished unrolling. */
  onUnrolled?: () => void;
};

type VariantConfig = {
  /** How long the paper takes to unroll. */
  unrollSeconds: number;
  /** Gap between one line settling and the following one. */
  stagger: number;
  /** How far a line travels as it settles, in px. */
  lift: number;
  /** Spring the paid badge lands on. */
  badge: { type: "spring"; stiffness: number; damping: number };
};

// A receipt is read, not watched, so the lines arrive and stop. The
// badge damping ratio (damping / 2√stiffness) stays at or above 0.86,
// and the unroll itself is an eased tween — paper that springs open
// looks like elastic.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a reveal. For an order history where receipts open constantly.
  subtle: {
    unrollSeconds: 0.3,
    stagger: 0.03,
    lift: 4,
    badge: { type: "spring", stiffness: 520, damping: 46 },
  },
  // You can watch the paper come out. All-purpose.
  default: {
    unrollSeconds: 0.46,
    stagger: 0.06,
    lift: 8,
    badge: { type: "spring", stiffness: 380, damping: 34 },
  },
  // A slower unroll for the one screen a shopper sees after paying.
  playful: {
    unrollSeconds: 0.62,
    stagger: 0.085,
    lift: 12,
    badge: { type: "spring", stiffness: 300, damping: 30 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The cleared-payment color stays literal — it carries meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const CLEARED = "#10B981";

const DEFAULT_ITEMS: ReceiptItem[] = [
  { id: "weekender", name: "Canvas weekender", detail: "Sand · 38 L", amount: "$148.00" },
  { id: "pouch", name: "Zip pouch", detail: "Two-pack", amount: "$34.00" },
  { id: "strap", name: "Webbing strap", detail: "Slate", amount: "$66.00" },
];

/** The torn edge, generated rather than drawn, so the tooth count can
 *  change with the paper width without a second asset. */
function tornEdgePath(width: number, height: number, teeth: number) {
  const step = width / teeth;
  let d = `M0 0`;
  for (let index = 0; index < teeth; index += 1) {
    d += ` L${((index + 0.5) * step).toFixed(2)} ${height} L${((index + 1) * step).toFixed(2)} 0`;
  }
  return `${d} Z`;
}

const PAPER_WIDTH = 252;
const TOOTH_HEIGHT = 7;

export default function ReceiptUnroll({
  variant = "default",
  merchant = "Northwind Supply",
  reference = "Reference NW-4821-06",
  items = DEFAULT_ITEMS,
  total = "$267.42",
  method = "Visa ending 4242 · 18 Aug",
  clearMs = 700,
  onUnrolled,
}: ReceiptUnrollProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [cleared, setCleared] = useState(false);

  const onUnrolledRef = useRef(onUnrolled);
  useEffect(() => {
    onUnrolledRef.current = onUnrolled;
  }, [onUnrolled]);

  useEffect(() => {
    const timer = setTimeout(() => setCleared(true), clearMs);
    return () => clearTimeout(timer);
  }, [clearMs]);

  const paperTone = tone(5);

  return (
    <div style={{ width: PAPER_WIDTH, fontSize: 13 }}>
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          padding: "12px 14px",
          borderRadius: "14px 14px 0 0",
          background: paperTone,
          borderTop: `1px solid ${tone(10)}`,
          borderLeft: `1px solid ${tone(10)}`,
          borderRight: `1px solid ${tone(10)}`,
          boxSizing: "border-box",
        }}
      >
        {/* The badge is a glyph, not type, so it may land on a spring. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{
            scale: cleared ? 1 : 0.6,
            opacity: cleared ? 1 : 0,
          }}
          transition={reduceMotion ? { duration: 0.16 } : cfg.badge}
          style={{
            width: 26,
            height: 26,
            flexShrink: 0,
            display: "grid",
            placeItems: "center",
            borderRadius: 999,
            background: CLEARED,
          }}
        >
          <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
            <path
              d="M3.4 8.4 6.3 11.3 12.6 5"
              stroke="#FFFFFF"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </motion.span>

        <span style={{ display: "grid", gap: 2, minWidth: 0 }}>
          {/* Both captions share one cell so the swap cannot shift the
              merchant name beside them, and neither ever changes size. */}
          <span style={{ display: "grid" }}>
            <motion.span
              initial={false}
              animate={{ opacity: cleared ? 0 : 0.55 }}
              transition={{ duration: 0.18, ease: "easeOut" }}
              style={{ gridArea: "1 / 1", fontSize: 12.5, fontWeight: 600 }}
            >
              Authorising payment
            </motion.span>
            <motion.span
              initial={false}
              animate={{ opacity: cleared ? 1 : 0 }}
              transition={{
                duration: 0.2,
                ease: "easeOut",
                delay: cleared ? 0.08 : 0,
              }}
              style={{ gridArea: "1 / 1", fontSize: 12.5, fontWeight: 600 }}
            >
              Payment cleared
            </motion.span>
          </span>
          <span style={{ fontSize: 11, opacity: 0.5 }}>{merchant}</span>
        </span>
      </div>

      {/* The paper is a genuine size change, so height tweens — eased,
          never sprung. The torn edge is part of the same growing box, so
          it travels down with the paper for free. */}
      <motion.div
        role="region"
        aria-label="Receipt"
        initial={false}
        animate={{ height: cleared ? "auto" : 0 }}
        transition={
          reduceMotion
            ? { duration: 0 }
            : { duration: cfg.unrollSeconds, ease: [0.3, 0, 0.2, 1] }
        }
        onAnimationComplete={() => {
          if (cleared) onUnrolledRef.current?.();
        }}
        style={{ overflow: "hidden" }}
      >
        <div
          style={{
            padding: "12px 14px 14px",
            background: paperTone,
            borderLeft: `1px solid ${tone(10)}`,
            borderRight: `1px solid ${tone(10)}`,
            boxSizing: "border-box",
          }}
        >
          <div style={{ fontSize: 10.5, opacity: 0.45, letterSpacing: 0.3 }}>
            {reference}
          </div>

          <div style={{ display: "grid", gap: 9, marginTop: 11 }}>
            {items.map((item, index) => (
              <motion.div
                key={item.id}
                initial={false}
                animate={{
                  opacity: cleared ? 1 : 0,
                  y: cleared || reduceMotion ? 0 : cfg.lift,
                }}
                transition={{
                  duration: 0.28,
                  ease: "easeOut",
                  delay: cleared && !reduceMotion ? 0.1 + index * cfg.stagger : 0,
                }}
                style={{ display: "flex", alignItems: "baseline", gap: 10 }}
              >
                <span style={{ display: "grid", gap: 1, minWidth: 0 }}>
                  <span style={{ fontSize: 12.5, fontWeight: 550 }}>
                    {item.name}
                  </span>
                  <span style={{ fontSize: 10.5, opacity: 0.5 }}>
                    {item.detail}
                  </span>
                </span>
                <span
                  style={{
                    marginLeft: "auto",
                    fontSize: 12.5,
                    fontVariantNumeric: "tabular-nums",
                  }}
                >
                  {item.amount}
                </span>
              </motion.div>
            ))}
          </div>

          <div
            aria-hidden
            style={{
              height: 1,
              margin: "12px 0",
              backgroundImage: `repeating-linear-gradient(to right, ${tone(
                22
              )} 0 4px, transparent 4px 8px)`,
            }}
          />

          <motion.div
            initial={false}
            animate={{
              opacity: cleared ? 1 : 0,
              y: cleared || reduceMotion ? 0 : cfg.lift,
            }}
            transition={{
              duration: 0.28,
              ease: "easeOut",
              delay:
                cleared && !reduceMotion ? 0.1 + items.length * cfg.stagger : 0,
            }}
            style={{ display: "flex", alignItems: "baseline", gap: 10 }}
          >
            <span style={{ fontSize: 12.5, fontWeight: 600 }}>Total paid</span>
            <span
              style={{
                marginLeft: "auto",
                fontSize: 17,
                fontWeight: 650,
                letterSpacing: -0.2,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {total}
            </span>
          </motion.div>

          <div style={{ fontSize: 10.5, opacity: 0.45, marginTop: 8 }}>
            {method}
          </div>
        </div>

        <svg
          aria-hidden
          viewBox={`0 0 ${PAPER_WIDTH} ${TOOTH_HEIGHT}`}
          preserveAspectRatio="none"
          style={{ display: "block", width: "100%", height: TOOTH_HEIGHT }}
        >
          <path
            d={tornEdgePath(PAPER_WIDTH, TOOTH_HEIGHT, 14)}
            style={{ fill: paperTone }}
          />
        </svg>
      </motion.div>
    </div>
  );
}

About this pattern

The screen a shopper actually reads after paying. A badge lands on the header the moment the card clears, then the paper unrolls downward on an eased height tween with its torn edge riding along the bottom, and the line items settle in a short cascade behind it. The unroll is a real size change so it tweens rather than springs — paper that bounces open reads as elastic. Only the badge is allowed a soft landing; every figure on the receipt holds one type size from the first frame to the last.

Post-payment receiptOrder confirmation screenPurchase summary after a card clearsReceipt reopened from purchase history

Where it shows up

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

  • 10:15
    Order confirmedRidgeline Supply · order 4821
    Ridgeline GTBone · US 9 · Qty 1$132.00
    Merino crew sock, 2-pack$24.00
    Shipping$0.00
    Tax$13.65
    Total$169.65
    Track order
    Order receipt

    The post-purchase screen builds its summary lines under a cleared-payment header.

Related patterns