Patungan: split bill mini app
The split bill prints out of a receipt slot, one paper step at a time.
"use client";/** * Receipt printer ported from evanyudis/patungan. * Sheet hierarchy matches Patungan main @ c429906 (light paper only): * names and totals stay visible, and item lines, fractions, Service, * and PB1 open from "Lihat rincian" or a person's row. Sizes, muted * type, dashed rules, and two-column meta stay. The feed is still one WAAPI transform * timeline in 12px steps. React state changes only when printing * starts or finishes. */import { useCallback, useLayoutEffect, useRef, useState } from "react";import { receiptFont } from "./receipt-font";import "./receipt-printer.css";const STEP = 12;export type ReceiptLength = "short" | "medium" | "long";export type ReceiptLine = { name: string; item: string; subtotal: number;};export type ReceiptPrinterProps = { /** Preset length. Ignored when `lines` is set. */ length?: ReceiptLength; /** Explicit receipt rows. Wins over `length`. */ lines?: readonly ReceiptLine[]; /** * Print on mount. When false, hold the sheet in the slot. * Callers start the feed by remounting with autoplay, or by flipping this on. */ autoplay?: boolean; /** Fires once when the feed finishes, including skip and reduced motion. */ onDone?: () => void; /** * Tap on the paper and the skip control finish the feed. * Page scrolling does not. Hover previews pass false. */ interactive?: boolean; className?: string;};const PRESETS: Record<ReceiptLength, readonly ReceiptLine[]> = { short: [{ name: "Evan", item: "Nasi goreng", subtotal: 40000 }], medium: [ { name: "Evan", item: "1/2 Nasi goreng", subtotal: 40000 }, { name: "Dita", item: "1/2 Nasi goreng", subtotal: 40000 }, { name: "Rio", item: "Sate ayam", subtotal: 50000 }, ], long: [ { name: "Evan", item: "1/2 Nasi goreng", subtotal: 40000 }, { name: "Dita", item: "Es teh", subtotal: 8000 }, { name: "Rio", item: "Sate ayam", subtotal: 50000 }, { name: "Mira", item: "Gado-gado", subtotal: 32000 }, { name: "Seno", item: "Nasi bakar", subtotal: 28000 }, { name: "Lala", item: "Kopi susu", subtotal: 18000 }, ],};function prefersReducedMotion() { return window.matchMedia("(prefers-reduced-motion: reduce)").matches;}function pushFrame(frames: Keyframe[], transform: string, offset: number) { const last = Number(frames[frames.length - 1]?.offset ?? -1); const next = Math.min(1, Math.max(last + 0.0008, offset)); if (next <= last) return; frames.push({ transform, offset: next });}/** 12px steps. Span stays inside 3–4.6s once the caller asks for a real feed. */function feedPlan(distance: number, duration: number) { const hidden = -distance; const count = Math.max(1, Math.ceil(Math.max(STEP, distance - 8) / STEP)); const span = duration <= 0.5 ? Math.max(0.2, Math.min(0.5, duration)) : Math.min(4.6, Math.max(3, 2.15 + count * 0.02)); const frames: Keyframe[] = [{ transform: `translateY(${hidden}px)`, offset: 0 }]; const shake: Keyframe[] = [{ transform: "translateX(0px)", offset: 0 }]; for (let index = 1; index <= count; index += 1) { const target = Math.max(hidden, Math.min(-8, hidden + index * STEP)); const shift = index % 2 === 0 ? -1 : 1; pushFrame(frames, `translateY(${target}px)`, (index - 0.38) / count); pushFrame(shake, `translateX(${shift}px)`, (index - 0.38) / count); pushFrame(frames, `translateY(${target}px)`, index / count); pushFrame(shake, `translateX(${index === count ? 0 : shift}px)`, index / count); } pushFrame(frames, String(frames[frames.length - 1]?.transform ?? `translateY(${hidden}px)`), 1); pushFrame(shake, "translateX(0px)", 1); return { frames, shake, span };}function formatIdr(value: number) { return new Intl.NumberFormat("id-ID", { style: "currency", currency: "IDR", maximumFractionDigits: 0, }).format(value);}function itemName(item: string) { if (!item.startsWith("1/2 ")) return item; return ( <> <span className="thermal-fraction">1/2 </span> {item.slice(4)} </> );}function personTotal(subtotal: number) { return formatIdr(subtotal * 1.15);}function ReceiptChevron() { return ( <svg className="thermal-chevron" viewBox="0 0 24 24" aria-hidden="true"> <path d="m6 9 6 6 6-6" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}function ReceiptPaper({ lines }: { lines: readonly ReceiptLine[] }) { const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set()); const grand = lines.reduce((sum, line) => sum + Math.round(line.subtotal * 1.15), 0); const allOpen = lines.length > 0 && lines.every((line) => open.has(line.name)); function toggleAll() { setOpen(allOpen ? new Set() : new Set(lines.map((line) => line.name))); } function togglePerson(name: string) { setOpen((current) => { const next = new Set(current); if (next.has(name)) next.delete(name); else next.add(name); return next; }); } return ( <> <header className="thermal-header"> <span className="thermal-mark" aria-hidden="true" /> <h3>PATUNGAN</h3> <p className="thermal-place">Makan malam</p> <div className="thermal-meta"> <span>Tanggal</span> <span>12 Okt 2026 · 19.30</span> </div> <div className="thermal-divider thermal-divider-double" aria-hidden="true" /> </header> <section className="thermal-group-summary" aria-label="Ringkasan patungan"> {lines.map((person) => ( <div key={person.name} className="thermal-line thermal-total"> <span title={person.name}>{person.name}</span> <i aria-hidden="true" /> <span className="thermal-person-amount">{personTotal(person.subtotal)}</span> </div> ))} <p className="thermal-note">+ Service 5% · PB1 10%</p> <div className="thermal-line thermal-grand-total" data-feed="grand"> <span>Grand total</span> <i aria-hidden="true" /> <span className="thermal-amount">{formatIdr(grand)}</span> </div> </section> <section className="thermal-details" aria-label="Rincian per orang"> <button type="button" className="thermal-details-toggle" onClick={toggleAll} aria-expanded={allOpen} > {allOpen ? "Tutup rincian" : "Lihat rincian"} </button> {lines.map((person) => { const expanded = open.has(person.name); return ( <section key={person.name} className="thermal-person"> <button type="button" className="thermal-line thermal-trigger thermal-total" aria-expanded={expanded} onClick={() => togglePerson(person.name)} > <span className="thermal-person-name" title={person.name}> {person.name} </span> <i aria-hidden="true" /> <span className="thermal-person-amount">{personTotal(person.subtotal)}</span> <ReceiptChevron /> </button> {expanded ? ( <div className="thermal-detail-content"> <p className="thermal-full-name">{person.name}</p> <div className="thermal-column-labels"> <span>Item</span> <span>Qty</span> <span>Jumlah</span> </div> <div className="thermal-item"> <span title={person.item}>{itemName(person.item)}</span> <span className="thermal-qty">1</span> <span>{formatIdr(person.subtotal)}</span> </div> <div className="thermal-breakdown"> <div className="thermal-line thermal-secondary"> <span>Subtotal</span> <i aria-hidden="true" /> <span>{formatIdr(person.subtotal)}</span> </div> <div className="thermal-line thermal-secondary"> <span>Service 5%</span> <i aria-hidden="true" /> <span>{formatIdr(person.subtotal * 0.05)}</span> </div> <div className="thermal-line thermal-secondary"> <span>PB1 10%</span> <i aria-hidden="true" /> <span>{formatIdr(person.subtotal * 0.1)}</span> </div> </div> </div> ) : null} </section> ); })} </section> <footer className="thermal-footer"> <div className="thermal-meta"> <span>Dibayar oleh</span> <span className="thermal-payer">Evan</span> </div> <div className="thermal-divider thermal-divider-double" aria-hidden="true" /> <div className="thermal-meta"> <span>Struk</span> <span className="thermal-receipt-id">#DEMO-012</span> </div> <p className="thermal-thanks"> Terima kasih <br /> Sampai makan berikutnya. </p> </footer> </> );}export function ReceiptPrinter({ length = "medium", lines, autoplay = true, onDone, interactive = true, className,}: ReceiptPrinterProps) { const rows = lines && lines.length > 0 ? lines : PRESETS[length]; const slotRef = useRef<HTMLDivElement>(null); const paperRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null); const animations = useRef<Animation[]>([]); const completed = useRef(false); const skipArmed = useRef(0); const onDoneRef = useRef(onDone); const interactiveRef = useRef(interactive); const [done, setDone] = useState(false); const printing = Boolean(autoplay) && !done; const held = !autoplay && !done; useLayoutEffect(() => { onDoneRef.current = onDone; interactiveRef.current = interactive; }); const complete = useCallback((skipped = false) => { if (completed.current) return; completed.current = true; animations.current.forEach((animation) => { try { animation.finish(); } catch { /* A detached animation may already be idle. */ } }); if (bodyRef.current) bodyRef.current.style.transform = "none"; if (paperRef.current) { paperRef.current.style.transform = "translateY(0px)"; paperRef.current.style.willChange = "auto"; } if (!skipped && !prefersReducedMotion() && interactiveRef.current && "vibrate" in navigator) { navigator.vibrate(8); } setDone(true); onDoneRef.current?.(); }, []); const finish = useCallback(() => { if (completed.current || !interactiveRef.current) return; complete(true); }, [complete]); useLayoutEffect(() => { const sheet = paperRef.current; const head = slotRef.current; if (!sheet || !head) return; completed.current = false; const paperHeight = Math.max(sheet.offsetHeight, 1); const distance = paperHeight - 2; sheet.style.willChange = "transform"; sheet.style.transform = `translateY(${-distance}px)`; const release = () => { animations.current.forEach((animation) => { animation.onfinish = null; animation.cancel(); }); animations.current = []; }; if (!autoplay) return release; if (prefersReducedMotion()) { complete(true); return release; } const { frames, shake, span } = feedPlan(distance, 4); const playback = sheet.animate(frames, { duration: span * 1000, easing: "linear", fill: "forwards", }); const buzz = bodyRef.current?.animate(shake, { duration: span * 1000, easing: "linear", fill: "forwards", }); animations.current = buzz ? [playback, buzz] : [playback]; playback.onfinish = () => { if (completed.current) return; if (bodyRef.current) bodyRef.current.style.transform = "none"; // 3px overshoot on a full sheet stays inside a 0.12 bounce. const settle = sheet.animate( [ { transform: "translateY(-8px)", offset: 0 }, { transform: "translateY(3px)", offset: 0.62 }, { transform: "translateY(0px)", offset: 1 }, ], { duration: 340, easing: "ease-out", fill: "forwards" }, ); animations.current = [settle]; settle.onfinish = () => { if (!completed.current) complete(false); }; }; skipArmed.current = performance.now() + 800; return () => { animations.current.forEach((animation) => { animation.onfinish = null; try { animation.commitStyles(); } catch { /* Detached elements have no style to preserve. */ } animation.cancel(); }); animations.current = []; sheet.style.willChange = "auto"; }; }, [autoplay, complete, length, lines]); return ( <div className={`receipt-printer printer receipt-printer-contained${className ? ` ${className}` : ""}`} data-printing={printing ? "true" : "false"} data-held={held ? "true" : "false"} data-length={length} > <div ref={bodyRef} className="printer-body"> <div className="printer-screen"> <span className="printer-screen-side" aria-hidden="true" /> <p className="printer-status"> {held ? ( "Siap" ) : printing ? ( "Mencetak…" ) : ( <> <svg className="printer-check" viewBox="0 0 16 16" aria-hidden="true"> <path d="M3.2 8.4 6.3 11.5 12.8 4.8" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> Selesai </> )} </p> <span className="printer-screen-side"> {printing && interactive ? ( <button type="button" className="printer-skip" onClick={finish}> Lewati cetak </button> ) : null} </span> </div> <div ref={slotRef} className="printer-slot" aria-hidden="true" /> </div> <div className="paper-window" role="region" aria-label="Struk tercetak" onPointerDownCapture={() => { if (performance.now() >= skipArmed.current) finish(); }} > <div className="printer-mouth" aria-hidden="true" /> <div ref={paperRef} className="paper-sheet"> <div className={`thermal-paper ${receiptFont.className}`}> <ReceiptPaper lines={rows} /> </div> </div> </div> <p className="sr-only" role="status"> {printing ? "Mencetak struk" : "Struk siap."} </p> </div> );}Build it yourself
This prompt has the receipt files and a usage example. Paste it into a chat to get the same receipt.
Why
When a group splits a bill, the moment that matters is when everyone sees their share. I wanted that moment to feel like a receipt printing. The paper starts inside the slot and feeds out until the total shows. Tap to skip to the end.
Feed timing
The paper moves 12px at a time and pauses between steps. Longer receipts take longer to print, but the feed always stays between 3 and 4.6 seconds. One WAAPI transform drives the whole feed, so a skip can finish on the compositor.
const STEP = 12;
const span = Math.min(4.6, Math.max(3, 2.15 + count * 0.02));Slot clipping and shadow
The window hides the paper until it passes the slot. The slot has an inset shadow, and a short gradient under its lip makes the paper look like it comes out of the printer.
.printer-slot {
box-shadow: inset 0 2px 3px rgb(0 0 0 / 0.9), 0 8px 10px rgb(0 0 0 / 0.45);
}
.paper-window { overflow: hidden; }Settle
The feed stops 8px short of its final position, overshoots by 3px, and settles. The bounce stays at 0.12 or less. It animates only the transform, so a tap can still skip it.
{ transform: "translateY(-8px)", offset: 0 }
{ transform: "translateY(3px)", offset: 0.62 }
{ transform: "translateY(0px)", offset: 1 }Reduced motion
If the system asks for reduced motion, the receipt doesn't print. It appears complete and the status says it's done. Anything around it fades with opacity only.
if (prefersReducedMotion()) complete(true);