Receipt Unroll
The payment clears and the receipt unrolls beneath it, line items settling in behind the torn edge.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Order receipt
The post-purchase screen builds its summary lines under a cleared-payment header.
Related patterns
- Price Change RollA recalculated total rolls digit by digit in the direction the money moved.
- Review SubmitStar fills bloom from the middle, then the form folds away and the posted review rises into its place.
- Checkout Step ProgressThe finished stage folds into a one-line recap while the following one opens and the rail fills.
