# 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.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/invoice-print. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Feedback.

## Anatomy

1. **Card**: The dialog that holds everything: a title, the printer and two actions.
2. **Status tile**: A green tray. Its arrow drops in as the sheet is torn off and comes back down when it has gone.
3. **Title**: Says the invoice is ready, with a quieter line under it on what to do next.
4. **Slot**: A soft pill with a dark slit. The paper comes out from under its lip, clipped there, so it never resizes a layer.
5. **Sheet**: The printed invoice as real text, on paper in both schemes. It feeds out a line at a time and settles with a little give.
6. **Total**: Between two dashed rules, the sum of the lines in the studio’s colour.
7. **New invoice**: Pulls the sheet back into the printer in 420 ms and prints the next invoice of the list.
8. **Download**: Waits until the sheet is still. Pressed, it calls `onDownload` (or saves a plain-text copy) and the sheet tears off into the tray.

## 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.

## Installation

Install it with the shadcn CLI, into components/invoice-print of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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`)}
    />
  );
}
```

## States

- **Printing**: A frame of the feed, held half out of the slot. Download waits, dimmed, until the paper is still.
- **Printed**: The sheet is out and settled; Download is ready and the invoice is announced to screen readers.
- **Downloaded**: The sheet has gone into the tray: a check in its place, and Download turns green.
- **Dark**: The card and printer go dark with `colorScheme="dark"` or a dark page; the sheet stays paper.
- **One line**: An invoice with a single item: the items take whatever room is left, so the sheet keeps its size.

## How it works

- 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.

## API reference

- **invoices** (`readonly Invoice[]`, default `INVOICES`): 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`, default `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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- 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.

## Source

### invoice-print/invoice-print.tsx

```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
 * time, its footer first, and settles. Download tears it off along the slot and drops it into the
 * tray; New invoice pulls it back into the printer and prints the next one.
 *
 * - The sheet keeps one size: it moves (a transform) under a clipping box whose top edge is the
 *   slot, so the paper's movement never resizes a layer (motion.ts).
 * - The first print is a CSS animation, so it plays as the page loads, before hydration and even
 *   without JavaScript. The next ones play the same easing with the Web Animations API.
 * - Download saves a plain-text copy, made here in the browser, on the reader's own press. The
 *   demo never downloads anything: it only plays the tear.
 * - While on screen and untouched, a ghost pointer downloads the invoice and prints a new one.
 *   Under reduced motion every change happens at once.
 * - Light or dark: every colour of the card is a light-dark() token in the CSS, so the scheme is
 *   the page's color-scheme, or the one the colorScheme prop sets. The sheet stays paper in both.
 */

/** Where the sheet is: coming out, out, torn off and falling, gone, or going back in. */
type Phase = 'printing' | 'printed' | 'tearing' | 'saved' | 'retracting';

/** A still state the component can open in: mid-print, printed, or downloaded. */
export type InvoicePrintState = 'printing' | 'printed' | 'saved';

/** Where a sheet opened mid-print is held: a little over half of it out of the slot. */
const HALF_OUT = `translateY(${+(0.45 * (PEEK - SHEET.height) - TAIL).toFixed(3)}em)`;

/** What to play once React has put a change on the page. */
type Job =
  | { kind: 'print'; byReader: boolean; delay: number }
  | { kind: 'retract'; from: string; byReader: boolean }
  | { kind: 'tear' };

const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** Saves `invoice` as a plain-text file, made here in the browser: nothing is uploaded. */
function saveAsText(invoice: Invoice, host: HTMLElement) {
  const file = new Blob([plainText(invoice)], { type: 'text/plain;charset=utf-8' });
  const url = URL.createObjectURL(file);
  const link = document.createElement('a');
  link.href = url;
  link.download = `${invoice.number}.txt`;
  link.hidden = true;
  host.append(link);
  link.click();
  link.remove();
  window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}

export function InvoicePrint({
  invoices = INVOICES,
  onDownload,
  autoplay = true,
  colorScheme,
  defaultState,
  className = '',
}: {
  /** The invoices it prints, in turn: the first on arrival, the next on each "New invoice". */
  invoices?: readonly Invoice[];
  /** Called with the invoice the reader downloads. Without it, a plain-text copy is saved. */
  onDownload?: (invoice: Invoice) => void;
  /** While on screen and untouched, a ghost pointer downloads the invoice and prints another. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /**
   * Opens still in this state, 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.
   */
  defaultState?: InvoicePrintState;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [phase, setPhase] = useState<Phase>(defaultState ?? 'printing');
  /** How many invoices have been printed before this one: it shows invoices[turn % length]. */
  const [turn, setTurn] = useState(0);
  /** The first print is the stylesheet's; once it is over, the component plays every move. */
  const [first, setFirst] = useState(!defaultState);
  /** Opened mid-print: the sheet is held half out until the first move. */
  const [held, setHeld] = useState(defaultState === 'printing');
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const titleId = useId();
  const textId = useId();

  const feed = useRef<HTMLDivElement>(null);
  const newButton = useRef<HTMLButtonElement>(null);
  const downloadButton = useRef<HTMLButtonElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  /** The latest phase and turn, for the handlers and the demo, which run between renders. */
  const live = useRef({ phase: (defaultState ?? 'printing') as Phase, turn: 0 });
  const job = useRef<Job | null>(null);
  /** Counts the moves: a move's ending only counts if no other has started since. */
  const moves = useRef(0);
  /** "New invoice" pressed while the sheet was falling: it prints once the sheet is gone. */
  const queued = useRef<{ byReader: boolean } | null>(null);

  const invoiceAt = (n: number) => invoices[n % invoices.length];
  const invoice = invoiceAt(turn);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  const go = (next: Phase) => {
    live.current.phase = next;
    setPhase(next);
  };

  /** Stops the sheet where it is, mid-move included, and returns its transform there. */
  const halt = () => {
    const sheet = feed.current;
    moves.current += 1;
    if (!sheet) return OUT;
    const now = getComputedStyle(sheet).transform;
    for (const animation of sheet.getAnimations()) animation.cancel();
    setFirst(false);
    setHeld(false);
    return now === 'none' ? OUT : now;
  };

  const printed = (byReader: boolean) => {
    go('printed');
    if (!byReader) return;
    const shown = invoiceAt(live.current.turn);
    announce(`Invoice ${shown.number} is ready, total ${money(totalOf(shown))}.`);
  };

  /** Prints the next invoice, `delay` ms from now; the paper starts in the printer. */
  const print = (byReader: boolean, delay = 0) => {
    live.current.turn += 1;
    setTurn(live.current.turn);
    if (reducedMotion()) {
      printed(byReader);
      return;
    }
    job.current = { kind: 'print', byReader, delay };
    go('printing');
  };

  /** "New invoice": a sheet that is out goes back in first; one that is falling goes first. */
  const next = (byReader: boolean) => {
    const { phase: now } = live.current;
    if (now === 'retracting') return;
    if (now === 'tearing') {
      queued.current = { byReader };
      return;
    }
    if (now === 'saved' || reducedMotion()) {
      print(byReader);
      return;
    }
    job.current = { kind: 'retract', from: halt(), byReader };
    go('retracting');
  };

  const download = (byReader: boolean) => {
    if (live.current.phase !== 'printed') return;
    if (byReader) {
      const shown = invoiceAt(live.current.turn);
      if (onDownload) onDownload(shown);
      else if (root.current) saveAsText(shown, root.current);
      announce(`Invoice ${shown.number} downloaded.`);
    }
    if (reducedMotion()) {
      go('saved');
      return;
    }
    halt();
    job.current = { kind: 'tear' };
    go('tearing');
  };

  // Plays the move React has just put on the page, before the browser paints it. The stylesheet
  // already holds the sheet where the move ends; the animation plays the way there.
  useLayoutEffect(() => {
    const move = job.current;
    const sheet = feed.current;
    job.current = null;
    if (!move || !sheet) return;
    const id = ++moves.current;
    /** Runs `done` when `animation` ends, unless another move has started by then. */
    const after = (animation: Animation, done: () => void) =>
      animation.finished.then(
        () => {
          if (id === moves.current) done();
        },
        // Overtaken by another move.
        () => {},
      );
    if (move.kind === 'print') {
      const animation = sheet.animate([{ transform: IN }, { transform: OUT }], {
        duration: FEED.duration,
        delay: move.delay,
        easing: feedEasing(),
        fill: 'backwards',
      });
      after(animation, () => printed(move.byReader));
    } else if (move.kind === 'retract') {
      const animation = sheet.animate([{ transform: move.from }, { transform: IN }], RETRACT);
      // A beat in between, as the printer takes up the paper.
      after(animation, () => print(move.byReader, 180));
    } else {
      sheet.animate(TEAR.fade, { duration: TEAR.duration });
      const animation = sheet.animate(TEAR.move, { duration: TEAR.duration });
      after(animation, () => {
        go('saved');
        const waiting = queued.current;
        queued.current = null;
        if (waiting) print(waiting.byReader);
      });
    }
  });

  // The first print is the stylesheet's (none under reduced motion): the invoice is ready once it
  // is over. It is announced when the component ships without its demo, as in a product, where
  // the card shows up because the reader made an invoice.
  const firstPrinted = useEffectEvent(() => {
    setFirst(false);
    printed(!autoplay);
  });
  useEffect(() => {
    const sheet = feed.current;
    // Opened in a still state: there is no first print to wait for.
    if (!sheet || defaultState) return;
    const id = moves.current;
    let alive = true;
    Promise.all(sheet.getAnimations().map((animation) => animation.finished)).then(
      () => {
        if (alive && id === moves.current) firstPrinted();
      },
      // Cancelled: "New invoice" took over.
      () => {},
    );
    return () => {
      alive = false;
    };
    // Read on mount only, as the first print plays once.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // The demo: once the invoice is out, the pointer downloads it; once it has gone, the pointer
  // prints a new one. Its downloads only play the tear, and nothing it does is announced.
  const demoAct = useEffectEvent((act: 'download' | 'next') =>
    act === 'download' ? download(false) : next(false),
  );
  const demo = autoplay && !defaultState && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    if (!demo || !stage || !ghost) return;
    return runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      /** Waits until the paper is still: printed, or gone. */
      const still = async () => {
        while (!['printed', 'saved'].includes(live.current.phase)) await wait(150);
      };
      await wait(600);
      for (;;) {
        await still();
        await wait(1500);
        if (live.current.phase === 'printed') {
          await moveTo(downloadButton.current);
          await wait(260);
          await press(() => demoAct('download'));
          await still();
          await wait(1200);
        }
        await moveTo(newButton.current);
        await wait(260);
        await press(() => demoAct('next'));
        await wait(400);
      }
    });
  }, [demo, root]);

  const out = phase === 'printing' || phase === 'printed';
  const done = phase === 'tearing' || phase === 'saved';
  const vars = {
    '--sheet-w': `${SHEET.width}em`,
    '--sheet-h': `${SHEET.height}em`,
    '--tail': `${TAIL}em`,
    '--peek': `${PEEK}em`,
    '--feed': FEED.easing,
    '--feed-ms': `${FEED.duration}ms`,
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      style={vars}
      data-color-scheme={colorScheme}
      data-phase={phase}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onPointerMove={(event) => {
        // A mouse over the stage pauses the demo, so the invoice never leaves from under it.
        if (event.pointerType === 'mouse') take();
      }}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.scene}>
        <div
          className={styles.card}
          data-part="card"
          role="group"
          aria-labelledby={titleId}
          aria-describedby={textId}
        >
          <div className={styles.head}>
            <span className={styles.tile} data-part="tile" aria-hidden="true">
              <TrayIcon className={styles.tray} />
            </span>
            <div>
              <h3 id={titleId} className={styles.title} data-part="title">
                Your invoice is ready
              </h3>
              <p id={textId} className={styles.text}>
                Check the details, then save your copy.
              </p>
            </div>
          </div>

          <div className={styles.printer}>
            <span className={styles.slot} data-part="slot" aria-hidden="true" />
            <div className={styles.well}>
              <div
                ref={feed}
                className={styles.feed}
                data-part="sheet"
                data-at={out ? 'out' : 'in'}
                style={held ? { transform: HALF_OUT } : undefined}
                data-first={first || undefined}
                inert={!out}
              >
                <span className={styles.shadow} aria-hidden="true" />
                <svg
                  className={styles.outline}
                  viewBox={OUTLINE.box}
                  preserveAspectRatio="none"
                  aria-hidden="true"
                >
                  <path d={phase === 'tearing' ? OUTLINE.torn : OUTLINE.whole} />
                </svg>
                <article
                  className={styles.paper}
                  aria-label={`Invoice ${invoice.number}`}
                  aria-busy={phase === 'printing'}
                >
                  <Sheet invoice={invoice} />
                </article>
              </div>
              <p className={styles.saved} data-part="saved" aria-hidden={phase !== 'saved'}>
                <CheckIcon className={styles.savedIcon} />
                <span className={styles.savedTitle}>Invoice downloaded</span>
                <span className={styles.savedText}>Print a new one whenever you need it.</span>
              </p>
            </div>
          </div>

          <div className={styles.actions} data-part="actions">
            <button
              ref={newButton}
              type="button"
              className={`${styles.button} ${styles.secondary}`}
              data-part="new"
              onClick={() => next(true)}
            >
              New invoice
            </button>
            <button
              ref={downloadButton}
              type="button"
              className={`${styles.button} ${styles.primary}`}
              data-part="download"
              data-state={done ? 'done' : phase === 'printed' ? 'ready' : 'busy'}
              aria-disabled={phase !== 'printed'}
              onClick={() => download(true)}
            >
              <span className={styles.face} aria-hidden={done}>
                <DownloadIcon className={styles.arrow} />
                Download invoice
              </span>
              <span className={styles.face} aria-hidden={!done}>
                <CheckIcon className={styles.check} />
                Downloaded
              </span>
            </button>
          </div>
        </div>
      </div>

      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### invoice-print/invoice-print.module.css

```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. */
  --accent: #1d1d1f;
  --accent-hover: #36363a;
  --accent-ink: #fff;
  --focus: #0a84ff;
  /* Done: the tile's arrow, the check and Download once downloaded; the tint behind the first
     two, and its edge. */
  --green: #1d8a4c;
  --green-tint: #e2f4e8;
  --green-line: rgb(29 138 76 / 0.12);
  /* The card's soft drop and the shade right under it; the shade under Download. */
  --shadow: rgb(30 30 40 / 0.3);
  --shadow-near: rgb(0 0 0 / 0.05);
  --button-shadow: rgb(0 0 0 / 0.2);
  /* The printer's face, top to bottom, with its highlight, edge and shade; the slit in it, top to
     bottom, and the lip under the slit. */
  --printer: #f5f5f7;
  --printer-low: #e9e9ec;
  --printer-highlight: #fff;
  --printer-line: rgb(0 0 0 / 0.045);
  --printer-shadow: rgb(0 0 0 / 0.18);
  --slit: #0d0d0f;
  --slit-low: #34343a;
  --slit-lip: rgb(255 255 255 / 0.9);
  /* The sheet's shade on the card, close and soft, and the slot's shade on the sheet. */
  --paper-shade: rgb(20 20 30 / 0.1);
  --paper-shadow: rgb(20 20 30 / 0.32);
  --lip-shade: rgb(0 0 0 / 0.12);
  /* The sheet is paper, at night too: its ground and edge, its type, its headings, its quieter
     lines and rules, and the studio's colour. */
  --paper: #fcfaf5;
  --paper-edge: rgb(60 50 30 / 0.12);
  --type: #34343a;
  --paper-ink: #1d1d1f;
  --soft: #8a8a90;
  --rule: #d2d2d7;
  --brand: #d4532a;
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e6e6e9, #111113);
    --surface-2: light-dark(#fff, #1c1c1e);
    --surface-3: light-dark(#f1f1f3, #2c2c2e);
    --surface-3-hover: light-dark(#e9e9ec, #363638);
    --ink: light-dark(#1d1d1f, #f5f5f7);
    --ink-muted: light-dark(#6e6e73, #a1a1a6);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.07));
    --dots: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.05));
    --card-line: light-dark(rgb(0 0 0 / 0.04), rgb(255 255 255 / 0.08));
    --button-line: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.06));
    --accent: light-dark(#1d1d1f, #f5f5f7);
    --accent-hover: light-dark(#36363a, #dcdce1);
    --accent-ink: light-dark(#fff, #1d1d1f);
    --focus: light-dark(#0a84ff, #3594ff);
    --green: light-dark(#1d8a4c, #30d158);
    --green-tint: light-dark(#e2f4e8, #1f3927);
    --green-line: light-dark(rgb(29 138 76 / 0.12), rgb(48 209 88 / 0.18));
    --shadow: light-dark(rgb(30 30 40 / 0.3), rgb(0 0 0 / 0.6));
    --shadow-near: light-dark(rgb(0 0 0 / 0.05), rgb(0 0 0 / 0.3));
    --button-shadow: light-dark(rgb(0 0 0 / 0.2), rgb(0 0 0 / 0.5));
    --printer: light-dark(#f5f5f7, #2c2c2e);
    --printer-low: light-dark(#e9e9ec, #242426);
    --printer-highlight: light-dark(#fff, rgb(255 255 255 / 0.1));
    --printer-line: light-dark(rgb(0 0 0 / 0.045), rgb(255 255 255 / 0.05));
    --printer-shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --slit: light-dark(#0d0d0f, #000);
    --slit-low: light-dark(#34343a, #0b0b0c);
    --slit-lip: light-dark(rgb(255 255 255 / 0.9), rgb(255 255 255 / 0.1));
    --paper-shade: light-dark(rgb(20 20 30 / 0.1), rgb(0 0 0 / 0.3));
    --paper-shadow: light-dark(rgb(20 20 30 / 0.32), rgb(0 0 0 / 0.65));
    --lip-shade: light-dark(rgb(0 0 0 / 0.12), rgb(0 0 0 / 0.3));
  }
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* The buttons start from the stage's text; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  font: inherit;
  color: inherit;
}

/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.scene {
  display: grid;
  place-items: center;
  padding: 1.85em 1em;
  /* The sheet, the card's and the stage's padding: 22.3em, in the stage's width. */
  font-size: clamp(11px, 100cqi / 22.6, 16px);
  line-height: 1.3;
}

/* The dialog card. */
.card {
  width: min(24.5em, 100%);
  padding: 1.25em 1.25em 1.15em;
  border-radius: 1.6em;
  background: var(--surface-2);
  box-shadow:
    0 0 0 1px var(--card-line),
    0 1px 2px var(--shadow-near),
    0 1.4em 2.8em -1.4em var(--shadow);
}

.head {
  display: flex;
  align-items: center;
  gap: 0.85em;
}
.tile {
  display: grid;
  flex: none;
  place-items: center;
  width: 2.6em;
  height: 2.6em;
  border-radius: 0.8em;
  background: var(--green-tint);
  box-shadow: inset 0 0 0 1px var(--green-line);
  color: var(--green);
}
.tray {
  width: 1.45em;
  height: 1.45em;
  overflow: visible;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* The arrow drops into the tray as an invoice is downloaded, and a new one comes down. */
.root[data-phase='tearing'] .tray [data-arrow] {
  opacity: 0;
  translate: 0 5px;
  transition:
    translate 0.24s ease-in,
    opacity 0.2s ease 0.06s;
}
.root[data-phase='saved'] .tray [data-arrow] {
  animation: arrive 0.45s var(--ease-out) 0.1s backwards;
}
@keyframes arrive {
  from {
    opacity: 0;
    translate: 0 -6px;
  }
}
.title {
  margin: 0;
  font-size: 1.06em;
  font-weight: 650;
  letter-spacing: -0.015em;
}
.text {
  margin: 0.2em 0 0;
  font-size: 0.84em;
  line-height: 1.35;
  color: var(--ink-muted);
}

/* The printer: a dark slit across the card, and under it the box the paper comes out into. The
   box clips the paper at the slot (its top edge) and at the tray (its bottom edge). */
.printer {
  position: relative;
  margin-top: 1.1em;
}
/* The printer's face, a soft pill across the card, and the dark slit in it. The paper comes out
   from under its lower lip: the box below starts there and clips it, so the face never has to be
   drawn over the moving paper (which would give it a layer of its own). */
.slot {
  position: relative;
  display: block;
  height: 1.06em;
  border-radius: 999px;
  background: linear-gradient(var(--printer), var(--printer-low));
  box-shadow:
    inset 0 0.06em 0 var(--printer-highlight),
    0 0 0 1px var(--printer-line),
    0 0.12em 0.25em -0.1em var(--printer-shadow);
}
.slot::before {
  content: '';
  position: absolute;
  top: 0.36em;
  right: 0.4em;
  left: 0.4em;
  height: 0.4em;
  border-radius: 999px;
  background: linear-gradient(var(--slit), var(--slit-low));
  box-shadow:
    inset 0 0.07em 0.14em rgb(0 0 0 / 0.8),
    0 0.05em 0 var(--slit-lip);
}
.well {
  position: relative;
  height: calc(var(--sheet-h) + 1.5em);
  overflow: hidden;
  overflow: clip;
}
/* The slot's lip shades the paper just under it... */
.well::before {
  content: '';
  position: absolute;
  z-index: 1;
  top: 0;
  left: 50%;
  width: var(--sheet-w);
  height: 0.6em;
  margin-left: calc(var(--sheet-w) / -2);
  background: linear-gradient(var(--lip-shade), rgb(0 0 0 / 0));
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.root:is([data-phase='tearing'], [data-phase='saved']) .well::before {
  opacity: 0.3;
}
/* ...and a falling sheet fades into the tray, under the buttons. */
.well::after {
  content: '';
  position: absolute;
  z-index: 1;
  right: 0;
  bottom: 0;
  left: 0;
  height: 1.7em;
  background: linear-gradient(rgb(255 255 255 / 0), var(--surface-2) 80%);
  pointer-events: none;
}

/* The paper: one box of a fixed size that only moves, its top part (--tail) always inside the
   printer. Printed, its top edge is at the slot; in the printer, only its serrated edge peeks
   out. The tear pivots on the slot's right end. */
.feed {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--sheet-w);
  height: calc(var(--tail) + var(--sheet-h));
  margin-left: calc(var(--sheet-w) / -2);
  transform-origin: 100% var(--tail);
}
.feed[data-at='out'] {
  transform: translateY(calc(-1 * var(--tail)));
}
.feed[data-at='in'] {
  transform: translateY(calc(var(--peek) - var(--tail) - var(--sheet-h)));
}
/* The first print, played by the stylesheet as soon as the card is on the page. */
.feed[data-first] {
  animation: print var(--feed-ms) var(--feed) 0.45s backwards;
}
@keyframes print {
  from {
    transform: translateY(calc(var(--peek) - var(--tail) - var(--sheet-h)));
  }
}
/* Its shadow on the card, under the sheet's sides and bottom. */
.shadow {
  position: absolute;
  top: var(--tail);
  right: 0.3em;
  bottom: 0.45em;
  left: 0.3em;
  border-radius: 0.25em;
  box-shadow:
    0 0.12em 0.3em var(--paper-shade),
    0 0.9em 1.5em -0.55em var(--paper-shadow);
}
/* The paper itself: its outline, filled, with a hairline that follows the teeth. */
.outline {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.outline path {
  fill: var(--paper);
  stroke: var(--paper-edge);
  stroke-width: 1px;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.paper {
  position: absolute;
  inset: 0;
  margin: 0;
  padding: calc(var(--tail) + 1.05em) 1.1em 1.3em;
  color: var(--type);
  cursor: text;
}

/* The invoice, at receipt scale. Every line keeps to one line. */
.receipt {
  display: flex;
  flex-direction: column;
  height: 100%;
  font-family:
    ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
  font-size: 0.64em;
  line-height: 1.5;
  font-variant-numeric: tabular-nums;
}
:where(.receipt) p {
  margin: 0;
}
.line,
.name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.masthead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  column-gap: 0.8em;
}
.mark {
  display: block;
  width: 2.9em;
  height: 2.9em;
}
.wordmark {
  overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, 'Segoe UI', sans-serif;
  font-size: 1.2em;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.15;
  color: var(--paper-ink);
  white-space: nowrap;
  text-overflow: ellipsis;
}
.reference {
  display: grid;
  justify-items: end;
  color: var(--soft);
  white-space: nowrap;
}
.kind {
  font-weight: 700;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--paper-ink);
}

.parties {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1.2em;
  margin-top: 1.25em;
}
.label {
  margin-bottom: 0.15em;
  font-size: 0.86em;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--soft);
}
.name {
  font-weight: 700;
  color: var(--paper-ink);
}

.items {
  width: 100%;
  margin-top: 1.15em;
  padding: 0.35em 0;
  border-collapse: separate;
  border-spacing: 0;
  border-top: 1px dashed var(--rule);
}
/* The column headings are for screen readers: their row takes no room. */
.items th {
  padding: 0;
}
:where(.items) td {
  padding: 0.3em 0;
  vertical-align: baseline;
}
/* The description takes what the figures leave, and gives way to them. */
.items td:first-child {
  width: 100%;
  max-width: 0;
}
.each {
  padding: 0 1.2em 0 0.8em;
  color: var(--soft);
  text-align: right;
  white-space: nowrap;
}
.sum {
  text-align: right;
  white-space: nowrap;
}

.total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1em;
  padding: 0.65em 0 0.7em;
  border-top: 1px dashed var(--rule);
  border-bottom: 1px dashed var(--rule);
}
.total span {
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}
.total strong {
  font-size: 1.55em;
  letter-spacing: -0.03em;
  color: var(--brand);
}

.payment {
  margin-top: auto;
  padding-top: 0.9em;
  color: var(--soft);
}
.thanks {
  margin-top: 0.75em;
  padding-top: 0.75em;
  border-top: 1px dashed var(--rule);
  letter-spacing: 0.04em;
  text-align: center;
}

/* Gone: a word in its place, until the next print. Its box is only as tall as its words, and it
   is hidden, not just transparent, the rest of the time: above the moving paper, a box that
   painted anything would need a layer of its own. */
.saved {
  position: absolute;
  top: 45%;
  right: 0;
  left: 0;
  display: grid;
  justify-items: center;
  gap: 0.3em;
  margin: 0;
  text-align: center;
  visibility: hidden;
  opacity: 0;
  translate: 0 -40%;
  transition:
    opacity 0.2s ease,
    translate 0.2s ease,
    visibility 0s linear 0.2s;
}
.root[data-phase='saved'] .saved {
  visibility: visible;
  opacity: 1;
  translate: 0 -50%;
  transition:
    opacity 0.35s ease 0.1s,
    translate 0.5s var(--ease-out) 0.1s,
    visibility 0s;
}
.savedIcon {
  width: 1.9em;
  height: 1.9em;
  margin-bottom: 0.35em;
  padding: 0.4em;
  border-radius: 50%;
  background: var(--green-tint);
  fill: none;
  stroke: var(--green);
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.savedTitle {
  font-size: 0.92em;
  font-weight: 600;
}
.savedText {
  font-size: 0.8em;
  color: var(--ink-muted);
}

/* The buttons. */
.actions {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
  gap: 0.6em;
  margin-top: 0.2em;
}
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  min-width: 0;
  height: 2.9em;
  padding: 0 0.9em;
  border: 0;
  border-radius: 0.95em;
  font-size: 0.9em;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color 0.25s ease,
    box-shadow 0.25s ease,
    opacity 0.25s ease,
    scale 0.2s var(--ease-out);
}
.button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.button:not([aria-disabled='true']):active {
  scale: 0.97;
}
.secondary {
  background: var(--surface-3);
  box-shadow: inset 0 0 0 1px var(--button-line);
}
@media (hover: hover) {
  .secondary:hover {
    background: var(--surface-3-hover);
  }
  .primary[data-state='ready']:hover {
    background: var(--accent-hover);
  }
}
/* Download shows one of its two faces, stacked in one cell and each centred: an arrow and
   "Download invoice", then a check and "Downloaded". */
.primary {
  display: inline-grid;
  background: var(--accent);
  box-shadow:
    0 1px 2px var(--button-shadow),
    inset 0 1px 0 rgb(255 255 255 / 0.12);
  color: var(--accent-ink);
}
.primary[data-state='busy'] {
  opacity: 0.45;
  cursor: default;
}
.primary[data-state='done'] {
  background: var(--green);
  cursor: default;
}

.face {
  display: inline-flex;
  grid-area: 1 / 1;
  align-items: center;
  justify-self: center;
  gap: 0.5em;
  transition: opacity 0.25s ease;
}
.face svg {
  flex: none;
  width: 1.2em;
  height: 1.2em;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.1;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition:
    scale 0.4s var(--ease-out),
    translate 0.3s ease-in;
}
.face:last-child,
.primary[data-state='done'] .face:first-child {
  opacity: 0;
}
.check {
  scale: 0.4;
}
.primary[data-state='done'] .arrow {
  translate: 0 0.4em;
}
.primary[data-state='done'] .face:last-child {
  opacity: 1;
  transition: opacity 0.25s ease 0.1s;
}
.primary[data-state='done'] .check {
  scale: 1;
  transition: scale 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) 0.1s;
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (max-width: 420px) {
  .scene {
    padding: 1.5em 0.75em;
  }
  .card {
    padding: 1.05em 0.9em 0.95em;
    border-radius: 1.35em;
  }
}

/* Final states at once. */
@media (prefers-reduced-motion: reduce) {
  .feed[data-first],
  .root[data-phase='saved'] .tray [data-arrow] {
    animation: none;
  }
  .root *,
  .root *::before,
  .root *::after {
    transition: none !important;
  }
}
```

### invoice-print/sheet.tsx

```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">
        {(
          [
            ['From', invoice.from],
            ['Bill to', invoice.billTo],
          ] as const
        ).map(([label, party]) => (
          <div key={label}>
            <p className={styles.label}>{label}</p>
            <p className={styles.name}>{party.name}</p>
            {party.lines.map((line, i) => (
              <p key={i} className={styles.line}>
                {line}
              </p>
            ))}
          </div>
        ))}
      </div>

      <table className={styles.items} data-part="items" aria-label="Items">
        <thead>
          <tr>
            {['Item', 'Quantity and price', 'Amount'].map((heading) => (
              <th key={heading} scope="col">
                <span className={styles.hidden}>{heading}</span>
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {invoice.items.map((item, i) => (
            <tr key={i} data-part="item">
              <td className={styles.line}>{item.description}</td>
              <td className={styles.each}>
                {item.quantity} × {amount(item.price)}
              </td>
              <td className={styles.sum}>{amount(item.quantity * item.price)}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <p className={styles.total} data-part="total">
        <span>Total due</span>
        <strong>{money(totalOf(invoice))}</strong>
      </p>

      <div className={styles.payment} data-part="payment">
        <p className={styles.line}>Pay by {invoice.due}</p>
        <p className={styles.line}>{invoice.payment}</p>
        <p className={styles.line}>Reference {invoice.number}</p>
      </div>

      <p className={styles.thanks}>Thank you for your business.</p>
    </div>
  );
}
```

### invoice-print/motion.ts

```tsx
/*
 * 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`;

/** The sheet's place, printed... */
export const OUT = `translateY(${em(-TAIL)})`;
/** ...and still in the printer, all but its edge. */
export const IN = `translateY(${em(PEEK - TAIL - SHEET.height)})`;

/** The feed's rhythm: how many pushes, each one's length in ms, and the pause after it. */
const PUSHES = 16;
const PUSH_MS = 62;
const PAUSE_MS = 46;
/** The give at the end, as a share of the whole feed, and how long the paper takes to settle. */
const GIVE = 0.014;
const SETTLE_MS = 300;

/**
 * The feed as one easing: `linear()` stops of (progress, time). Each push covers its share of the
 * sheet on an ease-out, sampled four times; every fifth pause is longer, as if the printer took
 * its time over a heavier line. The last push overshoots by GIVE and settles back on a small,
 * damped wobble.
 */
function feedCurve() {
  const stops: [number, number][] = [[0, 0]];
  let time = 0;
  for (let i = 0; i < PUSHES; i += 1) {
    const from = i / PUSHES;
    const to = i === PUSHES - 1 ? 1 + GIVE : (i + 1) / PUSHES;
    for (let k = 1; k <= 4; k += 1) {
      const u = k / 4;
      stops.push([from + (to - from) * (1 - (1 - u) ** 3), time + PUSH_MS * u]);
    }
    time += PUSH_MS;
    if (i < PUSHES - 1) {
      time += i % 5 === 4 ? PAUSE_MS * 2.2 : PAUSE_MS;
      stops.push([to, time]);
    }
  }
  for (let k = 1; k <= 8; k += 1) {
    const u = k / 8;
    const wobble = k === 8 ? 0 : GIVE * Math.exp(-4 * u) * Math.cos(2.6 * Math.PI * u);
    stops.push([1 + wobble, time + SETTLE_MS * u]);
  }
  const duration = time + SETTLE_MS;
  const points = stops.map(([value, at], i) =>
    i === 0 ? '0' : `${+value.toFixed(4)} ${+((at / duration) * 100).toFixed(2)}%`,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(duration) };
}

/** The print: its easing (a linear() staircase) and its length, in ms. */
export const FEED = feedCurve();

/** The feed's easing for `element.animate()`, or a plain ease-out where linear() is unknown. */
export const feedEasing = () =>
  CSS.supports('animation-timing-function', FEED.easing) ? FEED.easing : 'ease-out';

/** "New invoice": the sheet is pulled back into the printer, smoothly and fast. */
export const RETRACT = { duration: 420, easing: 'cubic-bezier(0.55, 0, 0.7, 0.3)' };

/**
 * "Download": tugged down and to the left, the sheet rips along the slot (it pivots on the slot's
 * right end, from the stylesheet's transform-origin), then drops into the tray as it fades.
 */
export const TEAR = {
  duration: 900,
  move: [
    { transform: OUT, easing: 'cubic-bezier(0.3, 0, 0.4, 1)' },
    {
      transform: `translateY(${em(0.45 - TAIL)}) rotate(-1.8deg)`,
      offset: 0.2,
      easing: 'cubic-bezier(0.4, 0, 0.6, 1)',
    },
    {
      transform: `translateY(${em(0.8 - TAIL)}) rotate(0.6deg)`,
      offset: 0.34,
      easing: 'cubic-bezier(0.5, 0, 0.85, 0.4)',
    },
    { transform: `translateY(${em(15 - TAIL)}) rotate(4deg)` },
  ] satisfies Keyframe[],
  fade: [{ opacity: 1 }, { opacity: 1, offset: 0.55 }, { opacity: 0 }] satisfies Keyframe[],
};

/** The teeth along a torn edge: how many across the sheet, and how deep, in em. */
const TEETH = 30;
const DEPTH = 0.34;

const point = (x: number, y: number) => `${+x.toFixed(3)} ${+y.toFixed(3)}`;
const W = SHEET.width;
const H = TAIL + SHEET.height;

/** The serrated bottom edge, from right to left: the tips point down. */
const bottom = Array.from({ length: TEETH * 2 + 1 }, (_, i) =>
  point(W * (1 - i / (TEETH * 2)), i % 2 ? H : H - DEPTH),
).join(' L');

/** The torn top edge, at the slot, from left to right: the same line, so it fits the next edge. */
const top = Array.from({ length: TEETH * 2 + 1 }, (_, i) =>
  point((W * i) / (TEETH * 2), i % 2 ? TAIL : TAIL - DEPTH),
).join(' L');

/** The paper's outline, in a box of SHEET.width × (TAIL + SHEET.height): whole, and torn off. */
export const OUTLINE = {
  box: `0 0 ${W} ${+H.toFixed(3)}`,
  whole: `M0 0 L${W} 0 L${bottom} Z`,
  torn: `M${top} L${bottom} Z`,
};
```

### invoice-print/invoices.ts

```tsx
/*
 * 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. */
  issued: string;
  due: string;
  from: Party;
  billTo: Party;
  /** One or two lines fit the sheet. */
  items: LineItem[];
  /** How to pay, in one short line. */
  payment: string;
};

const STUDIO: Party = { name: 'Wrenfold Studio', lines: ['14 Quarry Lane', 'Millbrook'] };
const PAYMENT = 'Bank transfer · Account ending 0427';

export const INVOICES: Invoice[] = [
  {
    number: 'INV-0042',
    issued: 'Oct 3, 2026',
    due: 'Oct 17, 2026',
    from: STUDIO,
    billTo: { name: 'Marta Ilves', lines: ['Ilves Bakery', '7 Orchard Row'] },
    items: [
      { description: 'Brand identity', quantity: 1, price: 96000 },
      { description: 'Menu illustrations', quantity: 2, price: 14000 },
    ],
    payment: PAYMENT,
  },
  {
    number: 'INV-0043',
    issued: 'Oct 6, 2026',
    due: 'Oct 20, 2026',
    from: STUDIO,
    billTo: { name: 'Tomas Reyna', lines: ['Harbour Lane Books', '22 Pier Street'] },
    items: [
      { description: 'Window posters', quantity: 3, price: 18500 },
      { description: 'Print-ready files', quantity: 1, price: 12000 },
    ],
    payment: PAYMENT,
  },
  {
    number: 'INV-0044',
    issued: 'Oct 9, 2026',
    due: 'Oct 23, 2026',
    from: STUDIO,
    billTo: { name: 'Ada Novak', lines: ['Novak Cycles', '3 Mill Yard'] },
    items: [{ description: 'Website design', quantity: 1, price: 215000 }],
    payment: PAYMENT,
  },
  {
    number: 'INV-0045',
    issued: 'Oct 13, 2026',
    due: 'Oct 27, 2026',
    from: STUDIO,
    billTo: { name: 'Lena Ferraz', lines: ['Ferraz Florals', '19 Bell Street'] },
    items: [
      { description: 'Label artwork', quantity: 4, price: 6500 },
      { description: 'Photo retouching', quantity: 6, price: 2500 },
    ],
    payment: PAYMENT,
  },
];

export const totalOf = (invoice: Invoice) =>
  invoice.items.reduce((sum, item) => sum + item.quantity * item.price, 0);

/** "1,240.00": cents as dollars, with thousands separators. */
export const amount = (cents: number) =>
  (cents / 100).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');

/** "$1,240.00". */
export const money = (cents: number) => `$${amount(cents)}`;

/** The invoice as plain text, 44 columns wide: the file a reader downloads. */
export function plainText(invoice: Invoice) {
  const width = 44;
  const rule = '-'.repeat(width);
  const row = (left: string, right: string) => `${left} ${right.padStart(width - left.length - 1)}`;
  return [
    invoice.from.name.toUpperCase(),
    ...invoice.from.lines,
    '',
    row('INVOICE', invoice.number),
    row('Issued', invoice.issued),
    row('Due', invoice.due),
    '',
    'BILL TO',
    invoice.billTo.name,
    ...invoice.billTo.lines,
    '',
    rule,
    ...invoice.items.map((item) => {
      const each = amount(item.price).padStart(8);
      const sum = amount(item.quantity * item.price).padStart(9);
      return row(item.description, `${item.quantity} x ${each}  ${sum}`);
    }),
    rule,
    row('TOTAL DUE', money(totalOf(invoice))),
    rule,
    '',
    invoice.payment,
    `Reference ${invoice.number}`,
    '',
    'Thank you for your business.',
    '',
  ].join('\n');
}
```

### invoice-print/icons.tsx

```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">
      <path d="M12 4.5v10.25M7.75 10.75 12 15l4.25-4.25M5.5 19.25h13" />
    </svg>
  );
}

export function CheckIcon({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <path d="m5.5 12.75 4.25 4.25 8.75-9.5" />
    </svg>
  );
}

/**
 * The made-up studio's mark: a W folded out of two ribbons, white on the studio's colour. It sits
 * on the sheet, so it uses the sheet's colours, not the current colour.
 */
export function Monogram({ className }: IconProps) {
  return (
    <svg className={className} viewBox="0 0 24 24" aria-hidden="true">
      <rect width="24" height="24" rx="6.5" fill="var(--brand)" />
      <path
        d="m5.75 7.5 3.1 9 3.15-7.25"
        fill="none"
        stroke="#fff"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="m12 9.25 3.15 7.25 3.1-9"
        fill="none"
        stroke="#fff"
        strokeOpacity="0.62"
        strokeWidth="2.2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}
```

### invoice-print/ghost-pointer.tsx

```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 {}

type Point = { x: number; y: number };

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: '#1c1c1e',
  stroke: '#fff',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px rgb(0 0 0 / 0.25))',
};

/** The arrow the demo moves around. It is decorative: the demo's effects are announced. */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### invoice-print/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```