litenBeta

Paper Receipt

A crumpled thermal receipt that prints out on demand and sways gently from its top edge, complete with a seeded barcode and QR code.

There is no button: the trigger is the receipt itself. A torn stub of the paper peeks out over its own shadow, hovering feeds the stub out a few pixels, and pressing pulls the full sheet down with a smooth drawer-like curve. Pressing the printed paper rolls it back up, faster than it opened. Once settled, the sheet hangs from its top edge and sways softly with a slight 3D lean. The crumple comes from an SVG turbulence light map plus a set of macro crease gradients, all with fixed seeds, and the barcode and QR pattern are generated from a pure seeded hash, so server and client always render the same paper. Built with Motion.

Installation

Complete the shared Setup first, then add Motion.

Terminal
bun add motion

Copy the component into components/ui/paper-receipt.tsx. No global CSS is needed; the sway runs through Motion so it can unwind smoothly on close.

components/ui/paper-receipt.tsx
'use client';import * as React from 'react';import { motion, useReducedMotion } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;const FOCUS =  'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--receipt-accent)]';export type ReceiptItem = { name: string; price: number };export type PaperReceiptProps = {  store?: string;  address?: string;  date?: string;  orderId?: string;  items?: ReceiptItem[];  taxRate?: number;  currency?: string;  accent?: string;  seed?: number;  defaultOpen?: boolean;  className?: string;};const DEFAULT_ITEMS: ReceiptItem[] = [  { name: 'RAMEN KIT', price: 12.0 },  { name: 'MISO PASTE', price: 4.5 },  { name: 'BUTTER, SALTED', price: 3.25 },  { name: 'EGGS (6)', price: 2.8 },  { name: 'CHILI OIL', price: 5.4 },];const CREASES = [  'linear-gradient(104deg, transparent 34%, rgba(0,0,0,0.045) 40%, rgba(255,255,255,0.65) 41%, transparent 46%)',  'linear-gradient(66deg, transparent 58%, rgba(0,0,0,0.04) 64%, rgba(255,255,255,0.6) 65%, transparent 70%)',  'linear-gradient(148deg, transparent 22%, rgba(0,0,0,0.038) 27%, rgba(255,255,255,0.55) 28%, transparent 33%)',  'linear-gradient(95deg, transparent 72%, rgba(0,0,0,0.042) 78%, rgba(255,255,255,0.6) 79%, transparent 84%)',  'linear-gradient(23deg, transparent 40%, rgba(0,0,0,0.03) 46%, rgba(255,255,255,0.5) 47%, transparent 52%)',  'linear-gradient(170deg, transparent 55%, rgba(0,0,0,0.035) 60%, rgba(255,255,255,0.55) 61%, transparent 66%)',  'conic-gradient(from 205deg at 28% 30%, rgba(0,0,0,0.035) 0deg 60deg, transparent 72deg 170deg, rgba(0,0,0,0.02) 185deg 240deg, transparent 255deg 360deg)',  'conic-gradient(from 20deg at 74% 68%, rgba(0,0,0,0.03) 0deg 50deg, transparent 65deg 180deg, rgba(0,0,0,0.022) 200deg 260deg, transparent 275deg 360deg)',].join(', ');function rand(seed: number, x: number, y: number): number {  let h = (seed * 374761393 + x * 668265263 + y * 2246822519) | 0;  h = Math.imul(h ^ (h >>> 13), 1274126177);  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;}function qrPath(seed: number): string {  const N = 21;  const grid: boolean[][] = Array.from({ length: N }, () => Array(N).fill(false));  const finder = (ox: number, oy: number) => {    for (let y = 0; y < 7; y++) {      for (let x = 0; x < 7; x++) {        const ring = x === 0 || x === 6 || y === 0 || y === 6;        const core = x >= 2 && x <= 4 && y >= 2 && y <= 4;        if (ring || core) grid[oy + y][ox + x] = true;      }    }  };  finder(0, 0);  finder(N - 7, 0);  finder(0, N - 7);  for (let i = 8; i < N - 8; i++) {    grid[6][i] = i % 2 === 0;    grid[i][6] = i % 2 === 0;  }  const reserved = (x: number, y: number) =>    (x < 8 && y < 8) || (x >= N - 8 && y < 8) || (x < 8 && y >= N - 8) || x === 6 || y === 6;  for (let y = 0; y < N; y++) {    for (let x = 0; x < N; x++) {      if (!reserved(x, y)) grid[y][x] = rand(seed, x, y) > 0.52;    }  }  let d = '';  for (let y = 0; y < N; y++) {    for (let x = 0; x < N; x++) {      if (grid[y][x]) d += `M${x} ${y}h1v1h-1z`;    }  }  return d;}function barcode(seed: number): { bars: { x: number; w: number }[]; width: number } {  const bars: { x: number; w: number }[] = [];  let x = 0;  for (let i = 0; i < 30; i++) {    const r = rand(seed + 1, i, 0);    const w = r > 0.8 ? 3 : r > 0.45 ? 2 : 1;    bars.push({ x, w });    x += w + (rand(seed + 2, i, 1) > 0.5 ? 2 : 1);  }  return { bars, width: x - 1 };}function zigzag(teeth: number): string {  const pts = ['0% 0%', '100% 0%'];  for (let i = teeth; i >= 0; i--) {    const x = ((i / teeth) * 100).toFixed(2);    pts.push(`${x}% ${i % 2 === 0 ? '100%' : 'calc(100% - 7px)'}`);  }  return `polygon(${pts.join(', ')})`;}function Dashed() {  return <span className="my-2 block border-t border-dashed border-neutral-400/70" />;}function Crumple({ id }: { id: string }) {  return (    <svg      aria-hidden      className="pointer-events-none absolute inset-0 h-full w-full opacity-70 mix-blend-multiply"    >      <filter id={id} x="0" y="0" width="100%" height="100%">        <feTurbulence          type="fractalNoise"          baseFrequency="0.019 0.026"          numOctaves="4"          seed="6"          result="noise"        />        <feDiffuseLighting in="noise" lightingColor="#ffffff" surfaceScale="2.3">          <feDistantLight azimuth="225" elevation="58" />        </feDiffuseLighting>      </filter>      <rect width="100%" height="100%" filter={`url(#${id})`} />    </svg>  );}export function PaperReceipt({  store = 'LITEN MART',  address = '124 DEPTH AVE, DAYLIGHT CITY',  date = '2026-07-04 12:42',  orderId = '#A-0042',  items = DEFAULT_ITEMS,  taxRate = 0.08,  currency = '$',  accent = '#f0883e',  seed = 7,  defaultOpen = false,  className,}: PaperReceiptProps) {  const reduce = useReducedMotion();  const id = React.useId().replace(/[:]/g, '');  const [open, setOpen] = React.useState(defaultOpen);  const [settled, setSettled] = React.useState(defaultOpen);  const [hovered, setHovered] = React.useState(false);  const paperRef = React.useRef<HTMLSpanElement>(null);  const [fullHeight, setFullHeight] = React.useState(0);  React.useLayoutEffect(() => {    const el = paperRef.current;    if (!el) return;    const measure = () => setFullHeight(el.offsetHeight);    measure();    const ro = new ResizeObserver(measure);    ro.observe(el);    return () => ro.disconnect();  }, []);  const qr = React.useMemo(() => qrPath(seed), [seed]);  const code = React.useMemo(() => barcode(seed), [seed]);  const clip = React.useMemo(() => zigzag(20), []);  const subtotal = items.reduce((sum, it) => sum + it.price, 0);  const tax = subtotal * taxRate;  const total = subtotal + tax;  const fmt = (n: number) => `${currency}${n.toFixed(2)}`;  return (    <div      style={{ '--receipt-accent': accent } as React.CSSProperties}      className={cn('flex w-[300px] max-w-full flex-col items-center', className)}    >      <motion.button        type="button"        aria-expanded={open}        aria-label={open ? 'Hide receipt' : 'View receipt'}        onClick={() => {          setOpen((o) => !o);          setSettled(false);        }}        onHoverStart={() => setHovered(true)}        onHoverEnd={() => setHovered(false)}        onFocus={() => setHovered(true)}        onBlur={() => setHovered(false)}        whileTap={reduce ? undefined : { scale: 0.985 }}        transition={{ duration: 0.16, ease: EASE_OUT }}        className={cn('relative flex cursor-pointer justify-center rounded-[6px]', FOCUS)}        style={{ perspective: 900 }}      >        <motion.span          className={cn(            'relative block',            '[filter:drop-shadow(0_10px_14px_rgba(0,0,0,0.16))]',            'dark:[filter:drop-shadow(0_12px_16px_rgba(0,0,0,0.5))]',          )}          style={{ transformOrigin: 'top center' }}          initial={false}          animate={            reduce              ? { rotateX: open ? 4 : 0, rotateZ: 0 }              : open && settled                ? { rotateX: [0, 6, 3, 0], rotateZ: [0, 2.6, -2.6, 0] }                : { rotateX: 0, rotateZ: 0 }          }          transition={            !reduce && open && settled              ? { duration: 5, ease: 'easeInOut', repeat: Infinity, times: [0, 0.3, 0.7, 1] }              : { duration: 0.6, ease: EASE_DRAWER }          }        >          <motion.span            className="block overflow-hidden"            style={{ clipPath: clip }}            initial={false}            animate={{              height: open ? fullHeight || 'auto' : !reduce && hovered ? 76 : 64,            }}            transition={              reduce                ? { duration: 0 }                : open                  ? { duration: 0.9, ease: EASE_DRAWER }                  : { duration: 0.55, ease: EASE_DRAWER }            }            onAnimationComplete={() => {              if (open) setSettled(true);            }}          >            <span              ref={paperRef}              className="relative block w-[240px] px-4 pb-7 pt-6 text-left font-mono text-[11px] leading-relaxed text-neutral-800"              style={{                background: 'linear-gradient(180deg, #ffffff 0%, #f7f7f5 100%)',                transform: 'translateZ(0)',              }}            >                <Crumple id={`crumple-${id}`} />                <span                  aria-hidden                  className="pointer-events-none absolute inset-0"                  style={{ background: CREASES }}                />                <p className="text-center text-[13px] font-bold tracking-[0.14em]">{store}</p>                <p className="mt-0.5 text-center text-[9px] tracking-[0.06em] text-neutral-500">                  {address}                </p>                <Dashed />                <p className="flex justify-between text-[10px] text-neutral-500">                  <span>{date}</span>                  <span>{orderId}</span>                </p>                <Dashed />                {items.map((it) => (                  <p key={it.name} className="flex justify-between gap-2">                    <span className="truncate">{it.name}</span>                    <span className="tabular-nums">{fmt(it.price)}</span>                  </p>                ))}                <Dashed />                <p className="flex justify-between text-neutral-500">                  <span>SUBTOTAL</span>                  <span className="tabular-nums">{fmt(subtotal)}</span>                </p>                <p className="flex justify-between text-neutral-500">                  <span>TAX ({Math.round(taxRate * 100)}%)</span>                  <span className="tabular-nums">{fmt(tax)}</span>                </p>                <p className="mt-1 flex justify-between text-[13px] font-bold">                  <span>TOTAL</span>                  <span className="tabular-nums">{fmt(total)}</span>                </p>                <Dashed />                <svg                  viewBox={`0 0 ${code.width} 24`}                  preserveAspectRatio="none"                  className="mt-1 h-7 w-full"                  aria-hidden                >                  {code.bars.map((b) => (                    <rect key={b.x} x={b.x} y="0" width={b.w} height="24" fill="#1f1f1f" />                  ))}                </svg>                <p className="mt-1 text-center text-[9px] tracking-[0.3em] text-neutral-500">                  0042 8817 3305                </p>                <div className="mt-3 flex items-center justify-center gap-3">                  <svg                    viewBox="0 0 21 21"                    className="size-[60px] shrink-0"                    shapeRendering="crispEdges"                    aria-hidden                  >                    <path d={qr} fill="#1f1f1f" />                  </svg>                  <p className="max-w-[80px] text-[9px] leading-snug tracking-[0.06em] text-neutral-500">                    SCAN FOR E-RECEIPT                  </p>                </div>                <p className="mt-3 text-center text-[10px] tracking-[0.2em] text-neutral-600">                  *** THANK YOU ***                </p>              </span>            </motion.span>        </motion.span>      </motion.button>    </div>  );}

Usage

Example.tsx
import { PaperReceipt } from '@/components/ui/paper-receipt';

export default function Example() {
  return <PaperReceipt />;
}

Examples

A cafe order in a cyan accent, printed by default.

Props

PropTypeDefaultDescription
storestring"LITEN MART"Store name printed at the top.
addressstring"124 DEPTH AVE, DAYLIGHT CITY"Address line under the store name.
datestring"2026-07-04 12:42"Timestamp line. Pass a string to stay SSR-stable.
orderIdstring"#A-0042"Order reference on the right of the date line.
items{ name: string; price: number }[]5 grocery itemsLine items with prices.
taxRatenumber0.08Tax as a fraction; subtotal and total are derived.
currencystring"$"Currency symbol used for all amounts.
accentstring"#f0883e"Accent for the focus ring.
seednumber7Seed for the barcode and QR pattern.
defaultOpenbooleanfalseStart with the receipt already printed.
classNamestring-Forwarded to the root.
On this page0%