Printed Receipt
An order receipt that feeds out of a thermal printer.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/printed-receipt.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }lib/usage/format.ts/** * Money formatting for usage and billing. Amounts are integer minor units (cents; sen for IDR). * IDR is shown without decimals. Locales are fixed by the caller, never read from the browser, * so server and client output match. */ const NO_DECIMALS = new Set(["IDR", "JPY", "KRW", "VND"]); export function moneyFormat(currency = "USD", locale = "en-US") { const decimals = NO_DECIMALS.has(currency) ? 0 : 2; const options = { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals } as const; const full = new Intl.NumberFormat(locale, options); const compact = new Intl.NumberFormat(locale, { style: "currency", currency, notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 }); return { currency, locale, decimals, /** "$55.80", "Rp 892.800". */ format: (minor: number) => full.format(minor / 100), /** "$1.2K", "Rp 1,2 jt". */ compact: (minor: number) => compact.format(minor / 100), /** Props for RollingDigits so totals roll instead of swapping. */ rolling: (minor: number) => ({ value: minor / 100, decimals, format: options, locale }), }; } export type MoneyFormat = ReturnType<typeof moneyFormat>; /** "427,200" in the given locale. */ export const formatCredits = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale).format(Math.round(credits)); /** Compact credits: "12.4K". */ export const formatCreditsCompact = (credits: number, locale = "en-US") => new Intl.NumberFormat(locale, { notation: "compact", maximumFractionDigits: 1 }).format(credits); /** A ratio as a percent: 0.558 → "56%". */ export const formatShare = (ratio: number, digits = 0) => `${(ratio * 100).toFixed(digits)}%`; /** A fixed, locale-stable date in UTC: "Nov 15" or "Nov 15, 2026". */ export function formatDay(iso: string, withYear = false, locale = "en-US") { return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", ...(withYear ? { year: "numeric" } : {}), timeZone: "UTC" }).format(new Date(`${iso}T00:00:00Z`)); }Add the theme tokens
This component uses --success and --warning, which shadcn does not define by default. Add them to your global CSS:
app/globals.css:root { --success: #15803d; --warning: #b45309; } .dark { --success: #4ade80; --warning: #fbbf24; } @theme inline { --color-success: var(--success); --color-warning: var(--warning); }Copy the source
components/ui/printed-receipt/printed-receipt.tsx"use client"; import { useId, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { duration } from "@/lib/motion"; import { moneyFormat } from "@/lib/usage/format"; export interface ReceiptItem { id: string; name: string; qty: number; /** Unit price in minor units (cents; sen for IDR). */ price: number; /** A variant or note line, e.g. "340 g · light roast". */ note?: string; } export interface ReceiptAdjustment { id: string; /** e.g. "Code BREWDAY" or "Shipping". */ label: string; /** Minor units; negative for discounts. Discounts reduce the taxable amount, other adjustments do not. */ amount: number; } export type ReceiptPayment = | { type: "card"; brand: string; lastFour: string } | { type: "crypto"; network: string; txHash: string; token: string; amount: string; gasFee: number }; export interface ReceiptOrder { number: string; /** ISO timestamp. */ date: string; items: ReceiptItem[]; adjustments?: ReceiptAdjustment[]; /** Tax rate as a ratio (0.0725 is 7.25%), applied to items minus discounts. */ taxRate?: number; payment: ReceiptPayment; delivery?: { method: string; to?: string }; } export interface PrintedReceiptProps { /** The store printed at the top. */ merchant: { name: string; address?: string[]; phone?: string }; /** The order. Money is integer minor units. */ order: ReceiptOrder; /** Stamp dropped on the total once printing finishes. @default "paid" */ status?: "paid" | "pending" | "refunded"; /** ISO 4217 currency. @default "USD" */ currency?: string; /** Locale for money and dates. @default "en-US" */ locale?: string; /** Time zone for the order date; fixed so server and browser agree. @default "UTC" */ timeZone?: string; /** Change this value to print the receipt again from the start. */ printKey?: string | number; /** Called when the paper has finished feeding out. */ onPrinted?: () => void; /** Thank-you line at the bottom. @default "Thank you!" */ footer?: string; /** Classes for the receipt. */ className?: string; } const STAMP: Record<NonNullable<PrintedReceiptProps["status"]>, { label: string; color: string }> = { paid: { label: "Paid", color: "color-mix(in oklab, var(--success) 85%, var(--foreground))" }, pending: { label: "Pending", color: "color-mix(in oklab, var(--warning) 85%, var(--foreground))" }, refunded: { label: "Refunded", color: "color-mix(in oklab, var(--muted-foreground) 90%, var(--foreground))" }, }; /** Thermal printers feed in short bursts: move, pause, move. Returns y keyframes and their times. */ function feedSteps(steps: number) { const y: string[] = ["-100%"]; const times: number[] = [0]; for (let i = 1; i <= steps; i++) { const at = -100 + (100 * i) / steps; const t0 = (i - 1) / steps; y.push(`${at.toFixed(2)}%`, `${at.toFixed(2)}%`); times.push(t0 + 0.55 / steps, i / steps); } return { y, times }; } /** A decorative barcode from the order number (not a scannable symbology). */ function Barcode({ value }: { value: string }) { const bars: { x: number; w: number }[] = []; let x = 0; for (const char of `*${value}*`) { const code = char.charCodeAt(0); for (let b = 0; b < 4; b++) { const w = 1 + ((code >> b) & 1) + ((code >> (b + 3)) & 1); bars.push({ x, w }); x += w + 1 + ((code >> (b + 1)) & 1); } } return ( <svg aria-hidden="true" viewBox={`0 0 ${x} 40`} preserveAspectRatio="none" className="h-10 w-full"> {bars.map((bar, i) => ( <rect key={i} x={bar.x} y={0} width={bar.w} height={40} fill="currentColor" /> ))} </svg> ); } const Divider = () => <div aria-hidden="true" className="my-3 border-t border-dashed border-current opacity-35" />; /** * An order receipt that feeds out of a thermal printer slot in short, jerky steps, on paper with a * torn zigzag edge. Once printed, a Paid, Pending or Refunded stamp drops onto the total. Change * `printKey` to print again. */ export function PrintedReceipt({ merchant, order, status = "paid", currency = "USD", locale = "en-US", timeZone = "UTC", printKey, onPrinted, footer = "Thank you!", className, }: PrintedReceiptProps) { const id = useId(); const reduceMotion = useReducedMotion(); const money = moneyFormat(currency, locale); const [printed, setPrinted] = useState(false); const [run, setRun] = useState(printKey); if (run !== printKey) { setRun(printKey); setPrinted(false); } const subtotal = order.items.reduce((sum, item) => sum + item.qty * item.price, 0); const adjustments = order.adjustments ?? []; const discounts = adjustments.filter((a) => a.amount < 0).reduce((sum, a) => sum + a.amount, 0); const tax = order.taxRate ? Math.round((subtotal + discounts) * order.taxRate) : 0; const total = subtotal + adjustments.reduce((sum, a) => sum + a.amount, 0) + tax; const date = new Date(order.date); const when = { day: new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone }).format(date), time: new Intl.DateTimeFormat(locale, { timeStyle: "short", timeZone }).format(date), }; const stamp = STAMP[status]; const { y, times } = feedSteps(Math.max(8, Math.min(18, order.items.length * 2 + 6))); const feedDuration = 2.6 + order.items.length * 0.12; const finish = () => { setPrinted(true); onPrinted?.(); }; const row = "flex items-baseline justify-between gap-3"; return ( <div className={cn("relative w-full max-w-[19rem]", className)}> {/* The printer: a dark lip with a slit the paper feeds out of. */} <div aria-hidden="true" className="relative z-10 h-5 rounded-t-xl rounded-b-md bg-[#1f1f22] shadow-[0_6px_10px_-6px_rgb(0_0_0/0.5)]"> <span className="absolute inset-x-4 bottom-1.5 h-1 rounded-full bg-black/80 shadow-[inset_0_1px_1px_rgb(0_0_0/0.8)]" /> <span className="absolute top-1.5 right-3 size-1.5 rounded-full bg-[#4ade80] opacity-90" /> </div> <div className="-mt-1.5 overflow-hidden px-3 pb-3"> <motion.article key={String(printKey ?? "receipt")} aria-labelledby={`${id}-title`} aria-busy={!printed} initial={reduceMotion ? { opacity: 0, y: "0%" } : { y: "-100%" }} animate={reduceMotion ? { opacity: 1, y: "0%" } : { y }} transition={reduceMotion ? { duration: duration.slow } : { duration: feedDuration, times, ease: "easeOut" }} onAnimationComplete={finish} className="relative bg-[#fbf7ee] px-5 pt-6 pb-8 font-mono text-[12px] leading-5 text-[#2a2723] shadow-[0_10px_24px_-12px_rgb(0_0_0/0.35)]" style={{ // Torn zigzag bottom edge. WebkitMaskImage: "conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg)", WebkitMaskSize: "12px 100%", WebkitMaskPosition: "50%", maskImage: "conic-gradient(from -45deg at bottom, #0000, #000 1deg 89deg, #0000 90deg)", maskSize: "12px 100%", maskPosition: "50%", backgroundImage: "repeating-linear-gradient(0deg, rgb(0 0 0 / 0.012) 0 1px, transparent 1px 3px), radial-gradient(circle at 30% 20%, rgb(0 0 0 / 0.02), transparent 60%)", }} > <header className="text-center"> <h3 id={`${id}-title`} className="text-[13px] font-bold tracking-wide uppercase"> {merchant.name} </h3> {merchant.address?.map((line) => ( <p key={line} className="text-[11px] opacity-75"> {line} </p> ))} {merchant.phone ? <p className="text-[11px] opacity-75">{merchant.phone}</p> : null} </header> <Divider /> <div className={cn(row, "text-[11px]")}> <span>{when.day}</span> <span>{when.time}</span> </div> <div className={cn(row, "text-[11px]")}> <span>Order</span> <span className="font-semibold">#{order.number}</span> </div> <Divider /> <ul className="space-y-1.5"> {order.items.map((item) => ( <li key={item.id}> <div className={row}> <span className="min-w-0">{item.name}</span> <span className="shrink-0 tabular-nums">{money.format(item.qty * item.price)}</span> </div> <p className="text-[11px] opacity-70"> {item.qty} × {money.format(item.price)} {item.note ? ` · ${item.note}` : ""} </p> </li> ))} </ul> <Divider /> <div className="space-y-0.5 tabular-nums"> <div className={row}> <span>Subtotal</span> <span>{money.format(subtotal)}</span> </div> {adjustments.map((a) => ( <div key={a.id} className={row}> <span>{a.label}</span> <span>{a.amount < 0 ? `−${money.format(-a.amount)}` : money.format(a.amount)}</span> </div> ))} {order.taxRate ? ( <div className={row}> <span>Tax {(order.taxRate * 100).toFixed(order.taxRate * 100 % 1 ? 2 : 0)}%</span> <span>{money.format(tax)}</span> </div> ) : null} </div> <div className="relative mt-2 border-t-2 border-current pt-2"> <div className={cn(row, "text-[15px] font-bold tabular-nums")}> <span>Total</span> <span className={cn("relative", status === "refunded" && printed && "line-through decoration-2")}>{money.format(total)}</span> </div> {printed ? ( <motion.span aria-hidden="true" className="pointer-events-none absolute top-1/2 left-[30%] rounded-md border-[2.5px] px-2.5 py-0.5 font-sans text-[15px] font-black tracking-[0.18em] uppercase mix-blend-multiply" style={{ color: stamp.color, borderColor: stamp.color }} initial={reduceMotion ? { opacity: 0, rotate: -9, y: "-50%" } : { opacity: 0, scale: 1.9, rotate: -16, y: "-50%", filter: "blur(2px)" }} animate={{ opacity: 0.88, scale: 1, rotate: -9, y: "-50%", filter: "blur(0.3px)" }} transition={reduceMotion ? { duration: duration.base } : { type: "spring", stiffness: 420, damping: 18, mass: 0.8 }} > {stamp.label} </motion.span> ) : null} </div> <Divider /> <div className="space-y-0.5 text-[11px]"> {order.payment.type === "card" ? ( <div className={row}> <span>Paid by</span> <span> {order.payment.brand} •••• {order.payment.lastFour} </span> </div> ) : ( <> <div className={row}> <span>Network</span> <span>{order.payment.network}</span> </div> <div className={row}> <span>Paid</span> <span> {order.payment.amount} {order.payment.token} </span> </div> <div className={row}> <span>Gas fee</span> <span className="tabular-nums">{money.format(order.payment.gasFee)}</span> </div> <div className={row}> <span>Tx</span> <span title={order.payment.txHash}>{`${order.payment.txHash.slice(0, 8)}…${order.payment.txHash.slice(-6)}`}</span> </div> </> )} {order.delivery ? ( <> <div className={row}> <span>Delivery</span> <span className="text-right">{order.delivery.method}</span> </div> {order.delivery.to ? ( <div className={row}> <span>To</span> <span className="text-right">{order.delivery.to}</span> </div> ) : null} </> ) : null} </div> <Divider /> <Barcode value={order.number} /> <p className="mt-1 text-center text-[10px] tracking-[0.3em]">{order.number}</p> <p className="mt-3 text-center text-[12px] font-semibold">{footer}</p> </motion.article> </div> <p aria-live="polite" className="sr-only"> {printed ? `Receipt printed. Total ${money.format(total)}, ${stamp.label.toLowerCase()}.` : ""} </p> </div> ); }
Usage
import { PrintedReceipt } from "@/components/ui/printed-receipt/printed-receipt";
<PrintedReceipt
merchant={{ name: "Kettle & Kiln Roasters", address: ["48 Harbour Lane", "Portside, NC 28411"] }}
order={{
number: "KK-20481",
date: "2026-11-02T09:41:00Z",
items: [{ id: "1", name: "Ethiopia Guji", qty: 2, price: 1850 }], // cents
adjustments: [{ id: "code", label: "Code BREWDAY", amount: -730 }],
taxRate: 0.0725,
payment: { type: "card", brand: "Orbit", lastFour: "4821" },
}}
status="paid"
printKey={orderId} // change it to print again
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
merchant* | { name: string; address?: string[]; phone?: string } | – | The store printed at the top. |
order* | ReceiptOrder | – | The order. Money is integer minor units. |
status | "paid" | "pending" | "refunded" | "paid" | Stamp dropped on the total once printing finishes. |
currency | string | "USD" | ISO 4217 currency. |
locale | string | "en-US" | Locale for money and dates. |
timeZone | string | "UTC" | Time zone for the order date; fixed so server and browser agree. |
printKey | string | number | – | Change this value to print the receipt again from the start. |
onPrinted | () => void | – | Called when the paper has finished feeding out. |
footer | string | "Thank you!" | Thank-you line at the bottom. |
className | string | – | Classes for the receipt. |
- Money is integer minor units. Tax is the rate times the items minus discounts (negative adjustments), rounded to the nearest minor unit; other adjustments such as shipping are not taxed.
- Dates use a fixed `timeZone` (UTC by default) so the server and the browser print the same time.
- The barcode is decorative and generated from the order number; it is not a scannable symbology. Payment lines take a card brand and last four only, or a crypto network, amount, gas fee and a truncated hash.
Accessibility
- The receipt is an article labelled by the store name, and every line is real text, so it reads in order while it prints; aria-busy is set until the paper has fed out.
- When printing finishes, the total and the status are announced politely. The stamp is decorative; Refunded also strikes through the total.
- With reduced motion the receipt fades in whole and the stamp appears without the drop.