Crumpled receipt
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Press and hold the receipt to crumple it into a ball and toss it in the bin.
Code
"use client";
import { animate, motion, useAnimationControls } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { rng, Surface } from "../ui";
/* Points around the receipt outline, in %, each with its own pull toward the middle. */
const OUTLINE = (() => {
const r = rng(7);
const pts: { x: number; y: number; k: number; jx: number; jy: number }[] = [];
const per = 9;
const edges: [number, number, number, number][] = [
[0, 0, 100, 0],
[100, 0, 100, 100],
[100, 100, 0, 100],
[0, 100, 0, 0],
];
for (const [x0, y0, x1, y1] of edges) {
for (let i = 0; i < per; i++) {
const t = i / per;
pts.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, k: 0.35 + r() * 0.5, jx: (r() - 0.5) * 18, jy: (r() - 0.5) * 18 });
}
}
return pts;
})();
const CREASES = (() => {
const r = rng(21);
return Array.from({ length: 14 }, () => ({ x1: r() * 100, y1: r() * 100, x2: r() * 100, y2: r() * 100 }));
})();
const LINES = [
["Oat flat white", "4.20"],
["Almond croissant", "3.80"],
["Sparkling water", "2.10"],
["Tip", "1.50"],
];
function polygon(p: number) {
return `polygon(${OUTLINE.map((o) => {
const x = o.x + (50 - o.x) * o.k * p + o.jx * p * (1 - p * 0.4);
const y = o.y + (50 - o.y) * o.k * p + o.jy * p * (1 - p * 0.4);
return `${x.toFixed(2)}% ${y.toFixed(2)}%`;
}).join(",")})`;
}
export default function CrumpledReceipt() {
const [p, setP] = useState(0);
const [tossed, setTossed] = useState(0);
const [round, setRound] = useState(0);
const holding = useRef(false);
const pRef = useRef(0);
const raf = useRef(0);
const anim = useRef<ReturnType<typeof animate> | null>(null);
const controls = useAnimationControls();
const busy = useRef(false);
const host = useRef<HTMLDivElement>(null);
const bin = useRef<HTMLDivElement>(null);
const paper = useRef<HTMLDivElement>(null);
useEffect(() => () => {
cancelAnimationFrame(raf.current);
anim.current?.stop();
}, []);
const set = (v: number) => {
pRef.current = v;
setP(v);
};
const toss = async () => {
holding.current = false;
busy.current = true;
const a = paper.current?.getBoundingClientRect();
const b = bin.current?.getBoundingClientRect();
const dx = a && b ? b.left + b.width / 2 - (a.left + a.width / 2) : 120;
const dy = a && b ? b.top + b.height * 0.35 - (a.top + a.height / 2) : 80;
await controls.start({
x: [0, dx * 0.55, dx],
y: [0, Math.min(dy, 0) - 90, dy],
rotate: [0, 200, 340],
scale: [1, 0.9, 0.6],
transition: { duration: 0.75, ease: "easeIn", times: [0, 0.45, 1] },
});
setTossed((t) => t + 1);
controls.set({ x: 0, y: "-120%", rotate: 0, scale: 1, opacity: 0 });
set(0);
setRound((r) => r + 1);
await controls.start({ y: 0, opacity: 1, transition: { type: "spring", stiffness: 180, damping: 20 } });
busy.current = false;
};
const tick = () => {
if (!holding.current) return;
const next = Math.min(1, pRef.current + 0.014 + pRef.current * 0.018);
set(next);
if (next < 1) raf.current = requestAnimationFrame(tick);
else toss();
};
const start = (e: React.PointerEvent) => {
if (busy.current) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
anim.current?.stop();
holding.current = true;
raf.current = requestAnimationFrame(tick);
};
const release = () => {
if (!holding.current) return;
holding.current = false;
cancelAnimationFrame(raf.current);
if (busy.current) return;
anim.current = animate(pRef.current, 0, {
type: "spring",
stiffness: 260,
damping: 16,
onUpdate: (v) => set(Math.max(0, v)),
});
};
const crumpling = p > 0.001;
return (
<Surface innerRef={host}>
<motion.div
ref={paper}
animate={controls}
onPointerDown={start}
onPointerUp={release}
onPointerCancel={release}
className="relative cursor-pointer touch-none"
>
<div
style={{
transform: `scale(${1 - p * 0.62}) rotate(${p * 24}deg)`,
filter: `drop-shadow(0 ${0.4 + p * 0.3}em ${0.8 - p * 0.3}em rgba(0,0,0,${0.08 + p * 0.12}))`,
}}
>
<div
className="relative w-[15.5em] bg-white px-[1.2em] pb-[1.6em] pt-[1.2em] font-mono text-[0.78em] text-[#151515]"
style={{
clipPath: crumpling ? polygon(p) : undefined,
WebkitMaskImage: crumpling
? undefined
: "conic-gradient(from -45deg at bottom, transparent 90deg, #000 0) bottom / 0.9em 51% repeat-x, linear-gradient(#000 0 0) top / 100% 51% no-repeat",
maskImage: crumpling
? undefined
: "conic-gradient(from -45deg at bottom, transparent 90deg, #000 0) bottom / 0.9em 51% repeat-x, linear-gradient(#000 0 0) top / 100% 51% no-repeat",
}}
>
<p className="text-center text-[1.05em] font-semibold tracking-[0.2em]">RECEIPT</p>
<p className="mb-[0.9em] text-center text-[0.85em] text-[#8a8984]">No. {String(1042 + round).padStart(5, "0")}</p>
{LINES.map(([a, b]) => (
<div key={a} className="flex justify-between py-[0.18em]">
<span>{a}</span>
<span>{b}</span>
</div>
))}
<div className="my-[0.6em] border-t border-dashed border-[#c9c8c3]" />
<div className="flex justify-between font-semibold">
<span>TOTAL</span>
<span>€11.60</span>
</div>
<p className="mt-[0.9em] text-center text-[0.8em] text-[#8a8984]">thank you · come again</p>
{/* creases appear as it crumples */}
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="pointer-events-none absolute inset-0 h-full w-full" style={{ opacity: p }}>
<rect width="100" height="100" fill="#bdbcb5" opacity={p * 0.75} />
{CREASES.map((c, i) => (
<line key={i} {...c} stroke={i % 2 ? "#ffffff" : "#7d7c76"} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
</svg>
</div>
</div>
</motion.div>
{/* the bin */}
<div ref={bin} className="absolute bottom-[8%] right-[8%] flex flex-col items-center gap-[0.3em]">
<div className="relative h-[3.4em] w-[2.9em]">
<div className="absolute inset-x-0 top-0 h-[0.45em] rounded-full bg-[#151515]" />
<div className="absolute inset-x-[0.2em] bottom-0 top-[0.3em] rounded-b-[0.5em] bg-[#d9d8d3] [clip-path:polygon(0_0,100%_0,88%_100%,12%_100%)]" />
</div>
<span className="font-mono text-[0.65em] text-[#8a8984]">{tossed}</span>
</div>
<p className="pointer-events-none absolute bottom-[6%] left-[8%] font-mono text-[0.7em] text-[#8a8984]">press and hold to crumple</p>
</Surface>
);
}
Prompt
Build "Crumpled receipt", a card animation. What it does: Press and hold the receipt to crumple it into a ball and toss it in the bin. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, a requestAnimationFrame loop, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).