Receipt (thermal 80mm)
A restaurant slip printed at the table. Narrow page, monospace stack, modifier lines, GST split, "PAID" stamp.
// Restaurant thermal receipt — narrow (80mm) mono-styled, the kind printed// at the table when you ask for the bill. Picks $ rather than ₹ because the// standard PDF mono fallback (Courier) only encodes WinAnsi and would fall// back to "?" for the rupee sign; users who register a real mono font with// rupee glyphs can re-skin this trivially.import { Rand, makeRestaurantOrder, moneyFixed } from "./lib/data";const SEED = 0x1eaf;export function RestaurantReceiptFixture() { const r = new Rand(SEED); const order = makeRestaurantOrder(SEED, 7); // Subtotal = sum of (qty × unit + modifier prices). Modifiers price is // per-line, not per-quantity, matching how POS systems usually charge. let subtotal = 0; for (const it of order.items) { subtotal += it.qty * it.unit; for (const m of it.modifiers) subtotal += m.price; } const cgst = Math.round(subtotal * 0.025); const sgst = Math.round(subtotal * 0.025); const service = Math.round(subtotal * 0.05); const total = subtotal + cgst + sgst + service; const txn = `0c${r.int(0x1000, 0xffff).toString(16)}`.toUpperCase(); return ( // The whole receipt is `font-mono` so it falls back to Courier (or any // mono the user registers) when exported. `text-[11px]` is the standard // thermal-printer body size; centered runs use `text-center`. The // ASCII separator rows are real text so they stay vector / selectable. <div className="font-mono text-[11px] leading-[15px] text-gray-900"> {/* ── Header ───────────────────────────────────────────────────── */} <header className="text-center"> <p className="text-[14px] font-bold uppercase tracking-[0.18em]">Little Fern</p> <p className="mt-0.5 text-[10px] text-gray-700">Bistro · Bar · Cafe</p> <p className="mt-2 text-[10px] leading-[14px] text-gray-700"> 221 Plum Court, Bengaluru 560001 <br /> +91 80 4123 5678 <br /> GSTIN 29ABCDE1234F2Z5 </p> </header> <Sep /> {/* ── Order meta ──────────────────────────────────────────────── */} <dl className="grid grid-cols-2 gap-x-3 gap-y-0.5 text-[10px]"> <dt className="text-gray-600">Order</dt> <dd className="text-right">#{order.orderNo}</dd> <dt className="text-gray-600">Date</dt> <dd className="text-right">12 Apr 2026 19:42</dd> <dt className="text-gray-600">Table</dt> <dd className="text-right">{order.table}</dd> <dt className="text-gray-600">Server</dt> <dd className="text-right">{order.server}</dd> <dt className="text-gray-600">Guests</dt> <dd className="text-right">{order.guests}</dd> </dl> <Sep /> {/* ── Items ───────────────────────────────────────────────────── */} <div className="text-[10px] uppercase tracking-[0.14em] text-gray-600"> <div className="flex items-baseline"> <span className="w-6 shrink-0">Qty</span> <span className="flex-1">Item</span> <span className="w-16 shrink-0 text-right">Amount</span> </div> </div> <Sep dashed /> <ul className="space-y-1"> {order.items.map((it) => { const lineBase = it.qty * it.unit; return ( <li key={`${it.name}`}> <div className="flex items-baseline"> <span className="w-6 shrink-0 tabular-nums">{it.qty}×</span> <span className="flex-1 pr-2">{it.name}</span> <span className="w-16 shrink-0 text-right tabular-nums"> {moneyFixed(lineBase)} </span> </div> {it.modifiers.map((m) => ( <div key={m.name} className="flex items-baseline pl-6 text-gray-600"> <span className="flex-1 pr-2">+ {m.name}</span> <span className="w-16 shrink-0 text-right tabular-nums"> {moneyFixed(m.price)} </span> </div> ))} </li> ); })} </ul> <Sep /> {/* ── Totals ──────────────────────────────────────────────────── */} <dl className="space-y-0.5"> <Row label="Subtotal" value={moneyFixed(subtotal)} /> <Row label="CGST 2.5%" value={moneyFixed(cgst)} muted /> <Row label="SGST 2.5%" value={moneyFixed(sgst)} muted /> <Row label="Service 5%" value={moneyFixed(service)} muted /> </dl> <Sep dashed /> <div className="flex items-baseline"> <span className="flex-1 text-[12px] font-bold uppercase tracking-[0.12em]">Total</span> <span className="w-20 shrink-0 text-right text-[14px] font-bold tabular-nums"> ${moneyFixed(total)} </span> </div> <Sep /> {/* ── Payment ─────────────────────────────────────────────────── */} <div className="text-center text-[10px] leading-[14px]"> <p> <span className="border border-gray-900 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em]"> Paid </span> <span className="ml-2 text-gray-700">UPI · *@oksbi</span> </p> <p className="mt-1 text-gray-600">TXN {txn}</p> </div> <Sep /> {/* ── Footer ──────────────────────────────────────────────────── */} <footer className="text-center text-[10px] leading-[14px] text-gray-700"> <p className="font-bold text-gray-900">Thank you · come again</p> <p className="mt-1">www.littlefern.example</p> <p className="mt-2 text-[9px] tracking-[0.16em] text-gray-500">|||| | || |||| | || ||||</p> </footer> </div> );}/** Full-width row: left label, right value, both monospaced. */function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) { return ( <div className={`flex items-baseline ${muted ? "text-gray-700" : ""}`}> <span className="flex-1">{label}</span> <span className="w-20 shrink-0 text-right tabular-nums">{value}</span> </div> );}/** Horizontal rule drawn as a 1px border. We use a real CSS border (not an * ASCII row of dashes) so the line stays sharp at any export resolution and * doesn't depend on the chosen mono font having a `─` glyph. */function Sep({ dashed = false }: { dashed?: boolean }) { return ( <div className={`my-2 border-t ${dashed ? "border-dashed border-gray-400" : "border-gray-900"}`} /> );}exportToPDF, so the page breaks you see here are the page breaks you'll get in the file.What you're looking at
A complete thermal-printer receipt — the kind a Bistro / Bar / Cafe POS prints after you ask for the bill. It exercises the patterns that come up every time a team wants their "screen receipt" to also be a downloadable PDF:
- Custom paper size. 80mm wide × 240mm tall. The library takes a
[width_mm, height_mm]tuple, so anything off the A-series chart works the same way. - Monospaced everything. Body text is a real mono font (falls back to Courier). The library subsets and embeds the font into the PDF, so the receipt looks identical on every machine that opens it.
- Modifier lines. Indented sub-rows under each item ("+ extra cheese", "+ no onion"). Vector text means the modifier names stay searchable in the PDF.
- GST split + service charge. Three muted rows under the subtotal, one bold total. No special PDF logic — it's the same Tailwind utilities you'd use for the screen.
- "PAID" stamp. A bordered inline-block with letter-spacing — vector, not raster.
- ASCII barcode strip in the footer. Real text characters, so the user can copy them. (Swap for an
<img src="...png">if you want a scannable code.)
The exporter call
Two pieces. The first is the size preset:
const RECEIPT_80MM = {
format: [80, 240], // mm × mm, the library converts internally to points
margin: "4mm", // small margins on a thermal slip
kind: "thermal",
};The second is the actual export, using the React DX layer:
import { usePDFExport, Printable, ExportButton } from "react-print-pdf/react";
function ReceiptPage({ order }) {
const exporter = usePDFExport({
fileName: `receipt-${order.orderNo}.pdf`,
paperSize: RECEIPT_80MM.format,
margins: RECEIPT_80MM.margin,
});
return (
<>
<ExportButton target={exporter.ref}>Download receipt</ExportButton>
<Printable ref={exporter.ref} options={exporter.options}>
<RestaurantReceipt order={order} />
</Printable>
</>
);
}That's all the export plumbing. Everything else is the component itself.
The component
Full source on GitHub — 164 lines, plain React + Tailwind.
The structural skeleton:
export function RestaurantReceipt({ order }) {
// 1. Compute totals from items + modifiers.
let subtotal = 0;
for (const it of order.items) {
subtotal += it.qty * it.unit;
for (const m of it.modifiers) subtotal += m.price;
}
const cgst = Math.round(subtotal * 0.025);
const sgst = Math.round(subtotal * 0.025);
const service = Math.round(subtotal * 0.05);
const total = subtotal + cgst + sgst + service;
return (
<div className="font-mono text-[11px] leading-[15px] text-gray-900">
<Header /> {/* business name, address, GSTIN */}
<Sep />
<OrderMeta order={order} />
<Sep />
<ItemColumns />
<Sep dashed />
<ItemList items={order.items} />
<Sep />
<TotalsRows {...{ subtotal, cgst, sgst, service }} />
<Sep dashed />
<GrandTotal total={total} />
<Sep />
<PaymentBlock txn={order.txn} />
<Sep />
<ReceiptFooter />
</div>
);
}
/** Horizontal rule drawn as a real 1px border. We don't print a row of
* ASCII '-' or '─' characters because the chosen mono fallback (Courier)
* doesn't always have the box-drawing glyph, and a CSS border stays sharp
* at any export resolution. */
function Sep({ dashed = false }) {
return (
<div
className={`my-2 border-t ${dashed ? "border-dashed border-gray-400" : "border-gray-900"}`}
/>
);
}Why the choices look the way they do
A couple of decisions in this fixture are deliberate, not stylistic. Worth calling out:
Why border-t for the separator and not '-'.repeat(32)
Because the separator is part of the layout, not part of the text. Drawing it as a CSS border keeps it sharp at any export resolution and survives a future change of mono font without checking whether that font ships a ─ glyph. WinAnsi encoding — what pdf-lib's built-in fonts use — doesn't include box-drawing characters; tracking which custom fonts do gets tedious fast.
Why $ and not ₹
The fixture uses $ as the currency symbol even though it's a Bengaluru bistro. Reason: the standard PDF built-in mono font (Courier) only encodes the WinAnsi range, and the rupee sign isn't in there. If you register a real mono TTF with rupee glyphs (and call registerFont before export), you can swap freely:
await registerFont({
family: "Mono",
url: "/fonts/IBMPlexMono-Regular.woff2",
weight: 400,
});Why the totals are integers
POS systems usually quote whole rupees / cents / paise — they round at line-item time, not at the display step. The fixture follows that convention so Math.round shows up explicitly rather than getting hidden behind a toFixed(2). Swap to fractional cents trivially with a Money helper.
Customizing it
The smallest "swap your branding in" path:
- Replace the
Headerblock with your own business name + address. - Edit
RECEIPT_80MMif you're on 58mm thermal paper instead. - Provide your own
ordershape —{ orderNo, table, server, guests, items: [{ name, qty, unit, modifiers: [{ name, price }] }] }. - Register a mono font with the glyphs your locale needs (currency symbols, accented characters).
Total work: ~20 minutes to swap a real menu in.
Related
- Best practices — width-matching, font registration, separators
- Fonts — registering a custom mono font with currency glyphs
- Sizing and alignment — how the custom paper size flows to
pageContentWidth