Receipt Printer
A checkout that prints its own receipt out of a slot when the order completes.
dark onlyA checkout that prints its own receipt out of a slot when the order completes.
dark onlyexperiments/receipt-printer/index.tsx
'use client'
import { useState } from 'react'
/**
* Printed groups, separated by a dashed rule. A line with no `value` is a
* sub-label of the line above it, not a row of its own.
*/
const GROUPS: Array<{
id: string
lines: Array<{ label: string; value?: string; head?: boolean; total?: boolean }>
}> = [
{
id: 'item',
lines: [{ label: 'PRO PLAN', value: '$192.00', head: true }, { label: 'Annual subscription' }],
},
{
id: 'totals',
lines: [
{ label: 'Subtotal', value: '$192.00' },
{ label: 'Tax', value: '$38.40' },
{ label: 'TOTAL PAID', value: '$230.40', total: true },
],
},
{
id: 'meta',
lines: [
{ label: 'Order', value: 'ORD-2048' },
{ label: 'Paid with', value: 'Visa •••• 4242' },
{ label: 'Date', value: '11 AUG 2026 · 14:32' },
],
},
]
const PROCESSING_MS = 1100
const FEED_MS = 1600
const PRINTED_MS = PROCESSING_MS + FEED_MS
export default function ReceiptPrinter() {
// Bumping the key remounts the whole animated tree, which restarts every
// animation-delay chain from zero — no timers to clear, nothing to resync.
const [run, setRun] = useState(0)
return (
<div className="flex w-[280px] flex-col gap-6">
<style>{KEYFRAMES}</style>
<button
type="button"
onClick={() => setRun((n) => n + 1)}
className="self-end rounded-full border border-white/15 bg-white/5 px-3 py-1.5 text-xs font-medium text-white/70 transition-colors hover:bg-white/10 hover:text-white"
>
<span className="inline-flex items-center gap-1.5">
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="13"
height="13"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.87" strokeLinecap="round" />
<path d="M13.5 2.8v3.3h-3.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Replay
</span>
</button>
<div key={run} className="relative">
{/* Machine */}
<div className="relative z-10 rounded-2xl bg-[#1c1c1c] px-4 pt-4 pb-5 shadow-[0_20px_50px_-20px_rgba(0,0,0,0.7)]">
<div className="mb-4 flex items-center justify-between">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-white/[0.06]">
<Sparkle className="text-white/25" size={13} />
</span>
<span className="flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-xs font-medium text-white/80">
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="11"
height="11"
fill="currentColor"
>
<path d="M8 1.5 1.5 6.8V14h4.2V9.6h4.6V14h4.2V6.8L8 1.5Z" />
</svg>
Home
</span>
</div>
<div className="rounded-xl bg-black/50 p-3.5">
<div className="flex items-start justify-between">
<div>
<div className="text-sm font-semibold text-white">Pro plan</div>
<div className="text-xs text-white/45">Annual subscription</div>
</div>
<div className="text-right">
<div className="text-[11px] text-white/45">Total</div>
<div className="text-base font-bold text-white">$230.40</div>
</div>
</div>
{/* Three stacked statuses, swapped by opacity at each stage edge. */}
<div className="relative mt-3.5 h-4 text-xs font-medium">
<div
className="absolute inset-0 flex items-center gap-2 text-white/55"
style={{ animation: `receipt-out 1ms ${PROCESSING_MS}ms forwards` }}
>
<Spinner />
Processing your order
</div>
<div
className="absolute inset-0 flex items-center gap-2 text-white/55 opacity-0"
style={{
animation: `receipt-in 1ms ${PROCESSING_MS}ms forwards, receipt-out 1ms ${PRINTED_MS}ms forwards`,
}}
>
<Spinner />
Printing your receipt
</div>
<div
className="absolute inset-0 flex items-center gap-2 text-white/85 opacity-0"
style={{ animation: `receipt-in 1ms ${PRINTED_MS}ms forwards` }}
>
<svg aria-hidden="true" viewBox="0 0 16 16" width="14" height="14" fill="none">
<circle cx="8" cy="8" r="7" fill="#22c55e" />
<path
d="M5 8.3 7.1 10.4 11.2 6"
stroke="#0b0b0b"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Order complete
</div>
</div>
</div>
{/* Slot the paper feeds through */}
<div className="mt-3.5 h-1.5 rounded-full bg-black/80" />
</div>
{/*
* The paper feeds out of the slot bottom-edge-first: the track grows
* downward (0fr → 1fr) while the sheet is pinned to its bottom, so the
* end of the receipt clears the slot first and the rest follows it out.
* Revealing top-down instead would read as text appearing on a static
* sheet rather than paper being pushed through.
*
* The negative top margin must stay equal to the machine's bottom
* padding: it lifts the paper's top onto the slot bar so the sheet looks
* like it leaves the black slot, not the machine's bottom edge. Paired
* with that, the paper sits ABOVE the machine (z-20) and covers the lip
* it crosses — behind it, the lip would hide the emerging strip and open
* the gap back up.
*/}
<div
className="-mt-5 relative z-20 mx-auto grid w-[88%]"
style={{
gridTemplateRows: '0fr',
animation: `receipt-feed ${FEED_MS}ms cubic-bezier(0.33, 1, 0.68, 1) ${PROCESSING_MS}ms forwards`,
}}
>
<div
className="relative flex min-h-0 items-end"
style={{
// Clips only the top, at the slot. `overflow: hidden` would clip
// every side and swallow the sheet's drop shadow; the negative
// insets let it fall below and beside the paper.
clipPath: 'inset(0 -40px -40px -40px)',
}}
>
<div
className="w-full shrink-0"
style={{ filter: 'drop-shadow(0 10px 14px rgba(0,0,0,0.45))' }}
>
<div className="bg-[#f2f0ea] px-4 pt-5 pb-2 text-[#171512]">
<div className="mb-4 flex justify-center">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#171512]">
<Sparkle className="text-[#f2f0ea]" size={17} />
</span>
</div>
{GROUPS.map((group) => (
<div key={group.id} className="border-t border-dashed border-black/25 py-2.5">
{group.lines.map((line) =>
line.value ? (
<div
key={line.label}
className={`flex items-baseline justify-between gap-3 font-mono tabular-nums ${
line.total ? 'mt-2.5' : 'mt-0.5 first:mt-0'
}`}
>
<span
className={
line.head || line.total
? 'text-[11px] font-bold tracking-tight'
: 'text-[11px] tracking-tight text-[#171512]/60'
}
>
{line.label}
</span>
<span
className={
line.total
? 'text-[15px] font-bold tracking-tight'
: 'text-[11px] tracking-tight'
}
>
{line.value}
</span>
</div>
) : (
<div
key={line.label}
className="font-mono text-[11px] tracking-tight text-[#171512]/60"
>
{line.label}
</div>
),
)}
</div>
))}
<div className="border-t border-dashed border-black/25 pt-4 pb-1">
<div className="flex justify-center gap-px">
{BARCODE.map((bar) => (
<span
key={bar.id}
className="bg-[#171512]"
style={{ width: bar.w, height: 30 }}
/>
))}
</div>
<div className="mt-1.5 text-center font-mono text-[8px] tracking-[0.3em] text-[#171512]/50">
ORD 2048
</div>
</div>
</div>
{/* Torn edge — in flow, so it rides the sheet's bottom out of the slot. */}
<div
className="h-1.5 bg-[#f2f0ea]"
style={{
// One downward tooth per tile: a ±45° wedge rising from the
// tile's bottom centre. The tile MUST be twice as wide as this
// strip is tall — at 45° a narrower tile is swallowed whole by
// the wedge and the edge renders flat.
maskImage:
'conic-gradient(from -45deg at bottom, transparent, black 1deg 89deg, transparent 90deg)',
maskSize: '12px 100%',
maskRepeat: 'repeat-x',
}}
/>
</div>
{/* Shadow cast by the machine onto the paper, fixed at the slot. */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-5 bg-gradient-to-b from-black/30 to-transparent" />
</div>
</div>
</div>
</div>
)
}
function Spinner() {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
style={{ animation: 'receipt-spin 750ms linear infinite' }}
>
<path d="M14 8a6 6 0 1 1-1.76-4.24" strokeLinecap="round" />
</svg>
)
}
function Sparkle({ className, size }: { className?: string; size: number }) {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
width={size}
height={size}
fill="currentColor"
className={className}
>
<path d="M8 0c.5 4 3.5 7.5 8 8-4.5.5-7.5 4-8 8-.5-4-3.5-7.5-8-8 4.5-.5 7.5-4 8-8Z" />
</svg>
)
}
// Deterministic bar widths, so every replay prints the same barcode.
const BARCODE = [
2, 1, 1, 3, 1, 2, 1, 1, 2, 3, 1, 1, 2, 1, 3, 1, 1, 2, 1, 1, 3, 2, 1, 1, 2, 1, 3, 1, 1, 2, 1, 3, 1,
1, 2, 1,
].map((w, id) => ({ id, w }))
const KEYFRAMES = `
@keyframes receipt-spin {
to { transform: rotate(360deg); }
}
@keyframes receipt-in {
to { opacity: 1; }
}
@keyframes receipt-out {
to { opacity: 0; }
}
@keyframes receipt-feed {
to { grid-template-rows: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 1ms !important; animation-delay: 0ms !important; }
}
`