Feedback & Data
ReceiptToast
The toast is a slip from a thermal printer. Paper feeds up out of the slot in small jerks, one line at a time, and you drag it up or press Tear to rip it off against the saw bar.
Download
- React
- Tailwind CSS v4
- motion
components/ui/receipt-toast.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type ReceiptItem = { label: string; price: string };
type ReceiptToastProps = {
/** Shop or event name, printed first. */
title?: string;
/** Small line under the title. */
meta?: string;
/** The lines of the receipt. */
items?: ReceiptItem[];
/** The total, in your own format. */
total?: string;
/** Last line, above the barcode. */
footer?: string;
/** Tears the paper off by itself this many ms after printing. 0 keeps it. */
duration?: number;
/** Called when the paper is torn off. */
onTear?: () => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Row = { kind: "title" | "meta" | "rule" | "item" | "total" | "footer" | "code"; left?: string; right?: string; h: number };
const ITEMS: ReceiptItem[] = [
{ label: "Monthly plan", price: "₹499" },
{ label: "Extra seat x 2", price: "₹198" },
{ label: "Discount", price: "-₹50" },
];
const LINE = 6.3; // one printed line, in cqw
const PAD = 2;
const TEETH = 22;
const BAR_WIDTHS = [1, 3, 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 2, 1, 3, 2, 1, 1, 3, 1, 2, 2, 1, 3, 1, 1, 2, 3, 1];
// Both ends of the paper are torn: a saw edge at top and bottom.
const EDGE = (() => {
const top: string[] = [];
const bottom: string[] = [];
for (let i = 0; i <= TEETH * 2; i++) {
const x = (i / (TEETH * 2)) * 100;
top.push(`${x}% ${i % 2 ? "1.1cqw" : "0"}`);
bottom.unshift(`${x}% calc(100% - ${i % 2 ? "1.1cqw" : "0px"})`);
}
return `polygon(${[...top, ...bottom].join(",")})`;
})();
/* ReceiptToast: the toast is a slip from a thermal printer. Paper feeds up out of the slot in small jerks, one line at a time, and you drag it up or press Tear to rip it off against the saw bar. */
export function ReceiptToast({ title = "Payment received", meta = "Order 20418 · Paid by UPI", items = ITEMS, total = "₹647", footer = "Thank you. Come again.", duration = 0, onTear, className = "w-full max-w-sm" }: ReceiptToastProps) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const seen = useInView(box, { once: true, amount: 0.2 });
const rows: Row[] = [
{ kind: "title", left: title, h: 1.5 },
{ kind: "meta", left: meta, h: 1 },
{ kind: "rule", h: 0.7 },
...items.map((it): Row => ({ kind: "item", left: it.label, right: it.price, h: 1 })),
{ kind: "rule", h: 0.7 },
{ kind: "total", left: "Total", right: total, h: 1.4 },
{ kind: "footer", left: footer, h: 1.1 },
{ kind: "code", h: 2 },
];
const height = rows.reduce((sum, r) => sum + r.h * LINE, PAD * 2);
const [run, setRun] = useState(0); // goes up by one for each slip
const [printed, setPrinted] = useState(0);
const [torn, setTorn] = useState(false);
const started = seen && run % 2 === 0;
const done = printed >= rows.length;
const printing = started && !done && !torn;
useEffect(() => {
if (!printing) return;
const id = setTimeout(() => setPrinted(reduce ? rows.length : (p) => p + 1), reduce ? 0 : 150);
return () => clearTimeout(id);
}, [printing, printed, reduce, rows.length]);
const tear = () => {
if (done && !torn) setTorn(true);
};
useEffect(() => {
if (!done || torn || !duration) return;
const id = setTimeout(() => setTorn(true), duration);
return () => clearTimeout(id);
}, [done, torn, duration]);
const out = rows.slice(0, printed).reduce((sum, r) => sum + r.h * LINE, printed ? PAD : 0);
const empty = run % 2 === 1;
return (
<div ref={box} className={`@container ${className}`}>
<div className="relative select-none" style={{ paddingTop: `${height}cqw` }}>
{/* Below the slot nothing shows; above it the paper is free to fly. */}
<div className="absolute inset-x-[9cqw] top-[-40cqw]" style={{ height: `${height + 41}cqw`, clipPath: "inset(0 -30cqw 0 -30cqw)" }} aria-hidden>
<motion.div
key={run}
className="absolute inset-x-0 bottom-0 cursor-grab touch-none active:cursor-grabbing"
drag={done && !torn ? "y" : false}
dragConstraints={{ top: -70, bottom: 0 }}
dragElastic={0.25}
dragSnapToOrigin
onDragEnd={(_, info) => {
if (info.offset.y < -34) tear();
}}
animate={torn ? { y: "-130%", x: "6%", rotate: -7, opacity: 0 } : { y: 0, x: 0, rotate: 0, opacity: 1 }}
transition={torn ? { duration: reduce ? 0 : 0.6, ease: [0.3, 0, 0.7, 1] } : { duration: 0 }}
onAnimationComplete={() => {
if (!torn) return;
setTorn(false);
setPrinted(0);
setRun((r) => r + 1);
onTear?.();
}}
>
<div
style={{
height: `${height}cqw`,
transform: `translateY(${height - out}cqw)`,
transition: reduce ? undefined : "transform 130ms steps(3)",
clipPath: EDGE,
padding: `${PAD}cqw 6cqw`,
background: "linear-gradient(90deg, #dcd7c8, #f6f3ea 8%, #fbf9f2 50%, #f3f0e6 92%, #d8d3c3)",
color: "#3f3c58",
fontFamily: "ui-monospace, 'Cascadia Mono', Consolas, monospace",
fontSize: "3.9cqw",
lineHeight: 1,
}}
>
{rows.map((r, i) => (
<div key={i} className="flex items-center justify-between gap-[2cqw] whitespace-nowrap" style={{ height: `${r.h * LINE}cqw` }}>
{r.kind === "rule" && <div className="w-full border-t border-dashed" style={{ borderColor: "#3f3c5899" }} />}
{r.kind === "code" && (
<div className="flex h-[8.4cqw] w-full justify-center">
{BAR_WIDTHS.map((w, b) => (
<span key={b} style={{ width: `${w * 0.62}cqw`, marginRight: `${(b % 3) * 0.3 + 0.28}cqw`, background: "#3f3c58" }} />
))}
</div>
)}
{r.kind === "title" && <span className="w-full text-center font-bold" style={{ fontSize: "5cqw" }}>{r.left}</span>}
{(r.kind === "meta" || r.kind === "footer") && <span className="w-full truncate text-center" style={{ fontSize: "3.1cqw", opacity: 0.78 }}>{r.left}</span>}
{(r.kind === "item" || r.kind === "total") && (
<>
<span className={`truncate ${r.kind === "total" ? "font-bold" : ""}`}>{r.left}</span>
<span className={r.kind === "total" ? "font-bold" : ""} style={r.kind === "total" ? { fontSize: "4.8cqw" } : undefined}>{r.right}</span>
</>
)}
</div>
))}
</div>
</motion.div>
{/* Shadow where the paper leaves the slot. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-[5cqw]" style={{ background: "linear-gradient(0deg, #0006, transparent)", opacity: printed ? 1 : 0 }} />
</div>
{/* The printer. */}
<motion.div
className="relative rounded-[3cqw]"
animate={printing && !reduce ? { y: [0, 0.5, 0] } : { y: 0 }}
transition={printing ? { duration: 0.15, repeat: Infinity } : { duration: 0.1 }}
style={{ padding: "1.4cqw 3cqw 3cqw", background: "linear-gradient(180deg, #3a3c41, #26272b 45%, #1c1d20)", boxShadow: "0 1.6cqw 3.4cqw #0008, inset 0 0.3cqw 0 #ffffff24" }}
>
{/* Saw bar with the slot under it. */}
<div className="h-[1.5cqw]" style={{ background: "repeating-linear-gradient(90deg, #cfd2d6 0 1.1cqw, #6f7378 1.1cqw 2.2cqw)", clipPath: "polygon(0 0, 100% 0, 100% 45%, 0 100%)" }} aria-hidden />
<div className="mx-[4cqw] h-[1.2cqw] rounded-b-[1cqw] bg-black" aria-hidden />
<div className="mt-[1.8cqw] flex items-center justify-between gap-[2cqw]">
<div className="flex items-center gap-[1.6cqw]" aria-hidden>
<motion.span
className="block size-[2.2cqw] rounded-full"
animate={printing && !reduce ? { opacity: [1, 0.3, 1] } : { opacity: 1 }}
transition={printing ? { duration: 0.4, repeat: Infinity } : { duration: 0.1 }}
style={{ background: printing ? "#6fe08a" : done ? "#f5b64a" : "#55585e", boxShadow: printing ? "0 0 2cqw #6fe08a" : done ? "0 0 1.6cqw #f5b64a" : "none" }}
/>
<span className="font-mono text-[2.7cqw] tracking-[0.14em]" style={{ color: "#9ea1a8" }}>{printing ? "PRINTING" : done ? "READY" : "IDLE"}</span>
</div>
<button
type="button"
disabled={printing || torn || !seen}
onClick={() => (done ? tear() : setRun((r) => r + 1))}
className="cursor-pointer rounded-[1.4cqw] px-[3.4cqw] py-[1.5cqw] font-mono text-[3.1cqw] font-semibold disabled:cursor-default disabled:opacity-40"
style={{ color: "#17181a", background: "linear-gradient(180deg, #e6e7ea, #b7b9be)", boxShadow: "0 0.5cqw 0 #6c6e73" }}
>
{empty || !done ? "Print" : "Tear"}
</button>
</div>
</motion.div>
</div>
<span role="status" className="sr-only">
{done ? `${title}. ${meta}. ${items.map((i) => `${i.label} ${i.price}`).join(", ")}. Total ${total}. ${footer}` : ""}
</span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/receipt-toast.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/receipt-toast-demo.tsx
import { ReceiptToast } from "@/components/ui/receipt-toast"; export default function ReceiptToastExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#26303a] p-2"> <ReceiptToast className="w-[min(92cqh,90cqw,380px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | "Payment received" | Printed first. |
| meta | string | order line | Small line under the title. |
| items | { label: string; price: string }[] | 3 lines | The lines of the receipt. |
| total | string | "₹647" | The total, in your own format. |
| footer | string | "Thank you. Come again." | Last line, above the barcode. |
| duration | number | 0 | Tears off by itself this many ms after printing. 0 keeps it. |
| onTear | () => void | - | Called when the paper is torn off. |
| className | string | "w-full max-w-sm" | Set the width here. Everything scales with it. |