Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Invoice print

An invoice that prints out of a slot like a receipt, footer first; download tears it off, and a new one feeds out.

invoice-print/invoice-print.tsx
Live · interactive

Your invoice is ready

Check the details, then save your copy.

Wrenfold Studio

InvoiceINV-0042Oct 3, 2026

From

Wrenfold Studio

14 Quarry Lane

Millbrook

Bill to

Marta Ilves

Ilves Bakery

7 Orchard Row

ItemQuantity and priceAmount
Brand identity1 × 960.00960.00
Menu illustrations2 × 140.00280.00

Total due$1,240.00

Pay by Oct 17, 2026

Bank transfer · Account ending 0427

Reference INV-0042

Thank you for your business.

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.

When to use

  • The moment a single document is ready to keep: an invoice, a receipt, an order or a statement.
  • A confirmation that should feel finished: the paper settles before Download is offered.
  • A short sheet with one or two lines, a total and how to pay, readable at a glance.
  • A product where a little ceremony fits, such as a studio, a shop or a booking flow.

When not to use

  • Long invoices with many lines. The sheet keeps one size; use a full document view or a PDF preview.
  • Lists of invoices, or billing history. Use a table with a download action per row.
  • Screens people open many times a day, where a 2-second print each time gets in the way. Use a plain card.
  • Editing an invoice. The sheet is a printout to check, not a form; edit it before it prints.

You can add this component with the shadcn CLI or by hand:

Into components/invoice-print of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/invoice-print.json

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.

Usage

issued-invoices.tsx
'use client';import { InvoicePrint } from './invoice-print';import type { Invoice } from './invoices';// The invoices just issued: the first prints as the card appears, the next on each "New invoice".export function IssuedInvoices({ issued }: { issued: Invoice[] }) {  return (    <InvoicePrint      invoices={issued}      autoplay={false}      onDownload={(invoice) => window.open(`/api/invoices/${invoice.number}.pdf`)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

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.
PropTypeDefaultDescription
invoicesreadonly Invoice[]INVOICESThe 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.
defaultStateInvoicePrintState—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.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

invoice-print.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,} from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { CheckIcon, DownloadIcon, TrayIcon } from './icons';import { INVOICES, money, plainText, totalOf, type Invoice } 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(0 0 0 / 0.06);  --dots: rgb(0 0 0 / 0.05);  --card-line: rgb(0 0 0 / 0.04);  --button-line: rgb(0 0 0 / 0.05);  /* Download: its fill, under the pointer, and its text; the focus ring. */
sheet.tsx
import { Monogram } from './icons';import { amount, money, totalOf, type Invoice } 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. */export function Sheet({ invoice }: { invoice: Invoice }) {  return (    <div className={styles.receipt}>      <div className={styles.masthead} data-part="masthead">        <Monogram className={styles.mark} />        <p className={styles.wordmark}>{invoice.from.name}</p>        <p className={styles.reference}>          <span className={styles.kind}>Invoice</span>          <span>{invoice.number}</span>          <span>{invoice.issued}</span>        </p>      </div>      <div className={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. */export const SHEET = { width: 19, height: 21 };/** How much paper stays in the printer, above the slot, in em: the settle never shows its end. */export const TAIL = 1.2;/** How far the paper's serrated edge peeks out of the slot between two sheets, in em. */export const PEEK = 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. */export type Party = {  /** A person or a company. */  name: string;  /** The lines under the name: a company, a street, a town. Two read best. */  lines: string[];};export type LineItem = {  description: string;  quantity: number;  /** The price of one, in cents. */  price: number;};export type Invoice = {  /** 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. */type IconProps = { className?: string };/** An arrow going down into a tray: the invoice is ready to download. */export function TrayIcon({ className }: IconProps) {  return (    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">      <path d="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" />      <g data-arrow="">        <path d="M12 4.25v10" />        <path d="m8.25 10.75 3.75 3.75 3.75-3.75" />      </g>    </svg>  );}/** The download button's arrow, onto a line. */export function DownloadIcon({ className }: IconProps) {  return (    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
ghost-pointer.tsx
'use client';import type { 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. */export type Demo = {  /** 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 Stopped extends Error {}
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. */export function useAutoplay<T extends HTMLElement>(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 = new IntersectionObserver(      ([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.