The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
'use client';import { InvoicePrint } from'./invoice-print';importtype { Invoice } from'./invoices';// The invoices just issued: the first prints as the card appears, the next on each "New invoice".exportfunctionIssuedInvoices({ issued }: { issued: Invoice[] }) {return (<InvoicePrintinvoices={issued}autoplay={false}onDownload={(invoice) => window.open(`/api/invoices/${invoice.number}.pdf`)}/> );}
The moment an invoice is ready, told as a receipt printer. A dialog card prints it out of a slit a line feed at a time, footer first, with a little give as the paper settles; Download tears it off along the slot and drops it into the tray, and New invoice pulls it back in and prints the next. The sheet is real, selectable text with a serrated edge, and Download saves a plain-text copy made in the browser.
The sheet is one box of a fixed size under a clipping box whose top edge is the slot. Printing only moves it with a transform, so the bottom of the invoice comes out first, the rest follows, and no layer ever changes size.
A whole print is one animation whose easing is a CSS linear() curve drawn as a staircase: sixteen short pushes, each eased out, with a pause after each (longer every fifth), then a small overshoot that settles back. The stylesheet plays it for the first print, so the invoice prints as the page loads, even without JavaScript; the Web Animations API replays the same curve for the next ones.
The paper is an SVG path under the invoice’s text, its bottom edge serrated where the last sheet was torn off. On Download its top edge takes the same teeth along the slot, so the two edges fit, while the sheet is tugged, tilts on the slot’s right end and drops into the tray as it fades.
New invoice pulls the sheet back into the printer from wherever it is, mid-print included, then prints the next invoice with its own number, date and amount.
Download builds a plain-text invoice in the browser and saves it through a Blob URL, only on the reader’s own press; onDownload can take its place. The demo plays the tear and downloads nothing.
The card and the stage are light-dark() tokens on the root, so they follow the page’s color-scheme, or the colorScheme prop; the printed sheet is paper, a document, and keeps its light colours at night.
The invoices it prints, in turn: the first as the card appears, the next on each New invoice. An invoice has a number, issue and due dates, who it is from and who it bills, one or two items (a description, a quantity and a price in cents) and a payment line.
defaultState
InvoicePrintState
—
Opens still, with no first print and no demo: 'printing' holds the sheet half out of the slot (Download waits), 'printed' has it out, 'saved' has it downloaded. New invoice and Download work from there as usual.
onDownload
(invoice: Invoice) => void
—
Called with the invoice the reader downloads. Without it, a plain-text copy made in the browser is saved.
autoplay
boolean
true
While on screen and untouched, a demo pointer downloads the invoice (the tear only, no file) and prints a new one.
colorScheme
'light' | 'dark' | 'system'
—
Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useLayoutEffect, useRef, useState,typeCSSProperties,} from'react';import { GhostPointer, runDemo } from'./ghost-pointer';import { CheckIcon, DownloadIcon, TrayIcon } from'./icons';import { INVOICES, money, plainText, totalOf, typeInvoice } from'./invoices';import { FEED, IN, OUT, OUTLINE, PEEK, RETRACT, SHEET, TAIL, TEAR, feedEasing } from'./motion';import { Sheet } from'./sheet';import { useAutoplay } from'./use-autoplay';import styles from'./invoice-print.module.css';/* * Invoice print: the moment an invoice is ready, told as a receipt printer. A dialog card says * the invoice is ready and prints it out of a slot: the paper feeds out downwards a line at a
invoice-print.module.css
/* Invoice print (invoice-print.tsx, sheet.tsx). Sizes are in em: the stage sets the font-size from its own width (a container query), up to 16px, so that the card and its 19em sheet always fit, and everything follows. The paper's geometry (--sheet-w, --sheet-h, --tail, --peek) and the feed's easing (--feed, --feed-ms) come from motion.ts, through the component's inline style. A light stage with a white card, or at night a near-black stage with a dark grey card; the sheet is paper in both. */.root {/* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root inherits from the page, or the one the colorScheme prop sets (below). *//* The stage, the card a step up, and New invoice on the card, then under the pointer. */--surface: #e6e6e9;--surface-2: #fff;--surface-3: #f1f1f3;--surface-3-hover: #e9e9ec;/* Text on the card: titles and buttons, then the lines under them. */--ink: #1d1d1f;--ink-muted: #6e6e73;/* The stage's edge and dots, the card's edge, New invoice's edge. */--line: rgb(000 / 0.06);--dots: rgb(000 / 0.05);--card-line: rgb(000 / 0.04);--button-line: rgb(000 / 0.05);/* Download: its fill, under the pointer, and its text; the focus ring. */
sheet.tsx
import { Monogram } from'./icons';import { amount, money, totalOf, typeInvoice } from'./invoices';import styles from'./invoice-print.module.css';/* * The printed invoice, as text a screen reader can read and a reader can select: the studio's * mark, the number and date, who it is from and who pays it, the items, the total due, how to pay * and a last line of thanks. Every line keeps to one line (the sheet's size never changes), and * the items take whatever room is left, so one item or two fit the same sheet. */exportfunctionSheet({ invoice }: { invoice: Invoice }) {return (<divclassName={styles.receipt}><divclassName={styles.masthead} data-part="masthead"><MonogramclassName={styles.mark} /><pclassName={styles.wordmark}>{invoice.from.name}</p><pclassName={styles.reference}><spanclassName={styles.kind}>Invoice</span><span>{invoice.number}</span><span>{invoice.issued}</span></p></div><divclassName={styles.parties} data-part="parties">
motion.ts
/* * The paper: its size, how it moves, and its outline. Sizes are in em of the card's text, so * the stylesheet and the animations agree whatever the card's size. * * - The feed. A receipt printer pushes the paper out a line at a time. The whole print is one * animation of the sheet's transform, and its easing is a CSS linear() curve drawn as a * staircase: a short push, eased out, then a pause, again and again, with a little give at the * end as the paper settles. The stylesheet plays it for the first print, so it runs before * JavaScript does; the component plays the same curve with the Web Animations API afterwards. * - The tear. The sheet is tugged down, rips along the slot and drops into the tray, fading. * - The outline. The paper is an SVG path, filled and edged with a hairline. Its bottom edge is * serrated where the last sheet was torn off; once torn, its top edge gets the same teeth, so * the two edges fit. The path is drawn in em, the units of the box it fills. *//** The sheet, in em. */exportconstSHEET = { width: 19, height: 21 };/** How much paper stays in the printer, above the slot, in em: the settle never shows its end. */exportconstTAIL = 1.2;/** How far the paper's serrated edge peeks out of the slot between two sheets, in em. */exportconstPEEK = 0.32;/** A length in em, rounded, for the keyframes. */const em = (value: number) => `${+value.toFixed(3)}em`;
invoices.ts
/* * The invoices: their shape, the sample ones the demo prints (a made-up studio billing made-up * clients), and the two ways out of the component, a total in dollars and a plain-text copy. * Amounts are in cents, so they always add up. */exporttypeParty = {/** A person or a company. */ name: string;/** The lines under the name: a company, a street, a town. Two read best. */ lines: string[];};exporttypeLineItem = { description: string; quantity: number;/** The price of one, in cents. */ price: number;};exporttypeInvoice = {/** As printed: "INV-0042". */ number: string;/** The dates, as printed. */
icons.tsx
/* * The icons, drawn on a 24 × 24 grid with round strokes in the current colour. All of them are * decorative: the text next to them says what they mean. */typeIconProps = { className?: string };/** An arrow going down into a tray: the invoice is ready to download. */exportfunctionTrayIcon({ className }: IconProps) {return (<svgclassName={className} viewBox="0 0 24 24"aria-hidden="true"><pathd="M4.5 13.5v3.25a2.75 2.75 0 0 0 2.75 2.75h9.5a2.75 2.75 0 0 0 2.75-2.75V13.5"/><gdata-arrow=""><pathd="M12 4.25v10"/><pathd="m8.25 10.75 3.75 3.75 3.75-3.75"/></g></svg> );}/** The download button's arrow, onto a line. */exportfunctionDownloadIcon({ className }: IconProps) {return (<svgclassName={className} viewBox="0 0 24 24"aria-hidden="true">
ghost-pointer.tsx
'use client';importtype { CSSProperties, Ref } from'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */exporttypeDemo = {/** Resolves after `ms`. */ wait: (ms: number) => Promise<void>;/** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;/** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class StoppedextendsError {}
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */exportfunction useAutoplay<TextendsHTMLElement>(idle = 6000) {const ref = useRef<T>(null);const [visible, setVisible] = useState(false);const [reduced, setReduced] = useState(true);const [taken, setTaken] = useState(false);const timer = useRef(0);useEffect(() => {const element = ref.current;if (!element) return;let onScreen = false;const update = () => setVisible(onScreen && !document.hidden);const observer = newIntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;
The card is a group named by its title, and New invoice and Download invoice are real buttons. Download is aria-disabled while the sheet prints, and reads “Downloaded” once the sheet has gone.
The sheet is text, not an image: an article named “Invoice INV-0042”, its items in a table with column headings for screen readers. It is aria-busy while it prints and inert once torn off or pulled back.
A print the reader starts is announced when it is complete (“Invoice INV-0042 is ready, total $1,240.00.”), and so is a download. The demo’s own prints and tears stay silent.
Under reduced motion the sheet is shown fully printed at once, a download or a new invoice swaps it at once, and the demo does not play.