Baktiyar Ablaikhan

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).