# Receipt printer

> A thermal receipt feeds out of a black printer, with vibration, status lights and real controls.

A payment confirmation becomes a physical receipt. The paper slides out of the printer’s slot while the body hums and its status lights pulse, then everything settles on Ready. Replay the print, copy the order number, change the paper or print a plain copy of the receipt.

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

Category: Feedback.

## Anatomy

1. **Payment banner**: A black card with a green tick and two lines: the payment went through and a receipt was issued.
2. **Status chip**: Idle, Printing or Ready, with a dot that is grey, pulsing amber or green to match.
3. **Printer**: A black body over the paper window, so its shadow falls on the receipt. It buzzes under a pixel while feeding.
4. **Status pill**: Standby, Feed or Online, its light glowing green while the paper moves.
5. **Receipt**: Monospace on thermal paper, with punched holes and a serrated bottom edge. It slides down from under the lip in one ease-out, 2.4 s by default.
6. **Total**: The sum of every line’s price times its quantity, formatted in the `currency` given.
7. **Actions**: Replay feeds a fresh sheet, Copy order puts the order number on the clipboard and says Copied for 2 s, Print opens the print dialog for a plain-text copy.
8. **Paper swatches**: Light, cream or dark paper. Until one is picked the paper follows the page’s colour scheme.

## When to use

- The moment right after a payment goes through, to confirm it with something to keep.
- Checkouts with a short order: one to six lines, a total, an order number worth copying.
- Point-of-sale, café and market products, where a till receipt is the familiar proof.
- A confirmation page with room for a 360 px column that can take a couple of seconds to arrive.

## When not to use

- Invoices with tax lines, discounts or many items. Use a real invoice table, or a PDF.
- Telling someone a payment failed or is pending: the banner only ever says it went through.
- Inside a busy flow where waiting 2.4 s for the paper slows people down. Show the summary at once.
- As the only record of a purchase. Send an email receipt too; this one isn’t saved anywhere.
- A narrow side panel or a toast. Use a one-line confirmation with the order number instead.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 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
import { ReceiptPrinter, type ReceiptItem } from './receipt-printer';

const items: ReceiptItem[] = [
  { name: 'Annual subscription', price: 200 },
  { name: 'Sticker pack', price: 4.5, quantity: 2, description: 'Matte vinyl' },
];

export function OrderConfirmation() {
  return (
    <ReceiptPrinter
      merchant="Your store"
      orderNumber="#4821"
      items={items}
      currency="EUR"
      autoplay={false}
      onPrintComplete={() => console.log('printed')}
    />
  );
}
```

## States

- **Idle**: Nothing printed yet (`autoPrint={false}`): an empty window, Standby, and Print starts the feed.
- **Feeding**: Held here partway out; live, the sheet keeps sliding, the lights pulse and Print waits until it settles.
- **Printed**: The receipt at rest, the lights steady green: Ready and Online.
- **Cream paper**: Picked on the swatches or with `defaultPaper`: the same on a light or a dark page.
- **Dark paper**: Light ink on near-black paper, on the light backdrop.
- **Dark scheme**: With `colorScheme="dark"` the backdrop, buttons and default paper turn dark; the banner and printer stay black.

## Examples

### Café order

A café’s order in euros with two coffees on one line, printed on cream paper through defaultPaper.

```tsx
import { ReceiptPrinter, type ReceiptItem } from './receipt-printer';

const breakfast: ReceiptItem[] = [
  { name: 'Flat white', price: 3.9, quantity: 2, description: 'Oat milk' },
  { name: 'Almond croissant', price: 3.6 },
  { name: 'Sparkling water', price: 2.8 },
];

export function CafeOrder() {
  return (
    <ReceiptPrinter
      merchant="Kestwell Café"
      tagline="Espresso bar & bakery"
      orderNumber="#127"
      paymentMethod="Contactless ···· 0917"
      footnote="Auth #31207 · Table 6"
      items={breakfast}
      currency="EUR"
      defaultPaper="cream"
      autoplay={false}
    />
  );
}
```

### Long receipt

A stationery shop’s five lines in yen, a currency without decimals; the window grows to the receipt’s length.

```tsx
import { ReceiptPrinter, type ReceiptItem } from './receipt-printer';

const stationery: ReceiptItem[] = [
  { name: 'Dot-grid notebook, A5', price: 1320 },
  { name: 'Gel pen, 0.38 mm', price: 165, quantity: 3 },
  { name: 'Washi tape, 15 mm', price: 286, quantity: 2 },
  { name: 'Fountain pen ink, 30 ml', price: 1650, description: 'Blue-black' },
  { name: 'Brass bookmark', price: 880 },
];

export function LongReceipt() {
  return (
    <ReceiptPrinter
      merchant="Kinari Stationery"
      tagline="Paper goods & writing tools"
      orderNumber="#20941"
      paymentMethod="Debit ···· 6130"
      footnote="Prices include 10% tax"
      items={stationery}
      currency="JPY"
      autoplay={false}
    />
  );
}
```

### Quick sale

A single item paid in cash; with printDuration at 0.8, Replay feeds it out in under a second.

```tsx
import { ReceiptPrinter, type ReceiptItem } from './receipt-printer';

const honey: ReceiptItem[] = [
  { name: 'Wildflower honey, 340 g', price: 8.5, description: 'Raw, unfiltered' },
];

export function QuickSale() {
  return (
    <ReceiptPrinter
      merchant="Tansy Hill Apiary"
      tagline="Raw honey & beeswax"
      orderNumber="#58"
      paymentMethod="Cash"
      footnote="Sunday market · Stall 14"
      items={honey}
      currency="GBP"
      printDuration={0.8}
      autoplay={false}
    />
  );
}
```

### On demand

With autoPrint off nothing prints by itself: the printer waits on Standby, its window empty, until Print or Replay feeds the receipt.

```tsx
import { ReceiptPrinter, type ReceiptItem } from './receipt-printer';

const rental: ReceiptItem[] = [
  { name: 'E-bike, full day', price: 59, quantity: 2 },
  { name: 'Pannier bag', price: 6 },
];

export function OnDemand() {
  return (
    <ReceiptPrinter
      merchant="Seeholz Bikes"
      tagline="E-bike rental by the lake"
      orderNumber="#7316"
      paymentMethod="Prepaid online"
      footnote="Bikes due back by 6 PM"
      items={rental}
      currency="CHF"
      autoPrint={false}
      autoplay={false}
    />
  );
}
```

## How it works

- The receipt is real text, laid out at rest inside a fixed-height window that clips it. A print is one CSS animation: the sheet slides down from 108% of its own height above its place while fading from 60% to 100%, on cubic-bezier(0.16, 1, 0.3, 1). Nothing reflows and the height never changes.
- Each print remounts the sheet, so a replay starts again from the top and the old paper vanishes at once. The printer turns Ready when the animation ends.
- While it feeds, the printer’s body jitters by under 0.6 px on a 180 ms cycle and both status lights pulse every 800 ms. With less than 15% of it on screen, or the tab hidden, all three pause where they are, so a print never finishes unseen.
- The demo prints 400 ms after the component can play, then every 11 s, while it is visible and untouched; it waits while the reader uses it and never cuts a feed short. Automatic prints are silent.
- Line totals are price × quantity, and every amount is formatted in the given currency. Copy puts the order number on the clipboard; Print opens the browser’s print dialog on a plain-text copy, built in a hidden frame that is removed afterwards.
- The stage is light-dark() tokens, so it follows the page’s color-scheme, or the colorScheme prop: a pale backdrop by day, a near-black one at night, with the banner and the printer black on both. Untouched, the paper follows the scheme too and its swatch shows pressed; picking light, cream or dark paper holds through scheme changes.

## API reference

- **items** (`ReceiptItem[]`): The receipt’s lines, in order: a name, a unit price, and an optional quantity and description. The subtotal and total are their sum.
- **merchant** (`string`, default `'Fernway Store'`): The shop’s name: in capitals on the receipt, and the first line of the printed copy.
- **tagline** (`string`, default `'Curated Component Catalog'`): The line under the shop’s name.
- **orderNumber** (`string`, default `'#4821'`): Shown and copied as given, and used in announcements and the print title; the barcode’s caption drops its first “#”.
- **paymentMethod** (`string`, default `'Card ···· 4242'`): How it was paid, for display only: the component charges nothing.
- **footnote** (`string`, default `'Auth #99824 · Fernway Ecosystem'`): The small print at the foot of the receipt, in capitals.
- **currency** (`string`, default `'USD'`): A three-letter currency code; every amount is formatted in it.
- **autoPrint** (`boolean`, default `true`): Print once it is seen (with autoplay off, it shows printed from the start); false leaves the window empty until Replay or Print.
- **printDuration** (`number`, default `2.4`): How long a print takes, in seconds (at least 0.2).
- **autoplay** (`boolean`, default `true`): Play the demonstration (a first print, then one every 11 s) while visible and untouched; false keeps it still, the receipt printed.
- **onPrintComplete** (`() => void`): Called each time a feed finishes, and when Replay or Print prints at once under reduced motion.
- **defaultPaper** (`'light' | 'cream' | 'dark'`): The paper to begin with, as if picked on the swatches. Without it the paper follows the colour scheme.
- **defaultState** (`'idle' | 'feeding' | 'printed'`): Opens still in this state, with the demonstration off and autoPrint ignored: the window empty, the sheet held partway out with the lights on until Replay, or printed.
- **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, the backdrop.

## Accessibility

- The banner is a region named “Payment status”. The receipt is an article named for its order, with a heading, description lists for the order details and the totals, and a real table of items with column headers. Icons, the barcode, the notches and the serrated edge are hidden from assistive technology.
- Replay, Copy order and Print are native buttons, followed by the light, cream and dark paper swatches (toggle buttons in a labelled group). While the paper feeds, Print is aria-disabled rather than disabled, so it keeps the focus.
- One polite status region speaks only what the reader started: printing, ready with the total, copied or failed, or that print preview is unavailable.
- Under reduced motion nothing moves or pulses and the receipt shows at rest; Replay and Print report it ready at once, and the demo never runs.

## Source

### receipt-printer/receipt-printer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type AnimationEvent,
  type CSSProperties,
  type RefObject,
} from 'react';
import { CopyIcon, PrinterIcon, ReplayIcon, TickIcon } from './icons';
import { useAutoplay } from './use-autoplay';
import styles from './receipt-printer.module.css';

/*
 * Receipt printer: a payment confirmation printed as a till receipt. A dark banner says the
 * payment went through; under it a desktop thermal printer feeds the receipt out from beneath its
 * front lip, bottom edge first, buzzing while it prints, its lights pulsing until the paper
 * settles.
 *
 * - The receipt is laid out at rest inside a window of fixed height that clips it, so nothing
 *   reflows: a print is one CSS animation that slides the whole sheet down from 108% of its own
 *   height above its place, fading it in from 60% opacity, on a long ease-out.
 * - Every print mounts a fresh sheet, so a replay never shows the old paper going back in.
 * - The printer turns ready when the sheet's animation ends. Off screen or in a hidden tab every
 *   animation pauses where it is, so a print can only finish while someone can see it.
 * - Print opens the browser's print dialog for a plain-text copy of the receipt in a hidden frame;
 *   Copy order puts the order number on the clipboard.
 * - While visible and untouched, the demo prints the first receipt shortly after it appears, then
 *   another every 11 s. Under reduced motion nothing moves: the receipt is simply shown printed.
 * - defaultState opens it still, in one state, with the demo off: 'feeding' holds the first sheet
 *   partway out, its animation paused, until Replay feeds a new one.
 * - Light or dark: the backdrop, the buttons and the paper are light-dark() tokens in the CSS, so
 *   they follow the page's color-scheme (or the colorScheme prop's) from the first frame; the
 *   banner and the printer stay black. Until a paper is picked, on the swatches or by
 *   defaultPaper, the paper is the scheme's, light or dark, and the swatches show it (read once
 *   mounted); cream is only ever a pick.
 */

export type ReceiptItem = {
  /** The line's label. */
  name: string;
  /** The unit price, in the receipt's currency. */
  price: number;
  /** How many: above 1 the name starts with a count, as in "2 × ". 1 when left out. */
  quantity?: number;
  /** A short note printed under the name, in small italics. */
  description?: string;
};

type Phase = 'idle' | 'feeding' | 'printed';
type Paper = 'light' | 'cream' | 'dark';
type Scheme = 'light' | 'dark';

const PAPERS: Paper[] = ['light', 'cream', 'dark'];

const SAMPLE_ITEMS: ReceiptItem[] = [
  { name: 'Fernway Pro License', price: 200 },
  { name: 'Animation Bundle', price: 20, description: 'Micro-interactions & physics' },
];

/** The receipt's fixed lines. */
const DATE = 'Aug 23, 2026 · 10:42 AM';
const THANKS = 'Thank you for your order!';

/** What the banner's chip and the printer's status pill say in each state. */
const CHIP: Record<Phase, string> = { idle: 'Idle', feeding: 'Printing', printed: 'Ready' };
const PILL: Record<Phase, string> = { idle: 'Standby', feeding: 'Feed', printed: 'Online' };

/** The demo's timing, in ms: the first print after the printer is seen, then the cadence. */
const FIRST_PRINT = 400;
const REPEAT = 11000;
/** How long the Copy button says "Copied", in ms. */
const COPIED_FOR = 2000;
/** The share of the printer that must be on screen for its animations to run. */
const SEEN_AT = 0.15;
/** How far through its feed a sheet held by defaultState="feeding" stands, as a share of the print. */
const HELD_AT = 0.06;

/**
 * The colour scheme `root` is drawn in: the CSS gives it an unseen colour, white in light and
 * black in dark, read once mounted and again whenever the scheme may have changed: an attribute
 * of an ancestor (where pages keep their theme), the system's setting, or `colorScheme`. null
 * until read: the server cannot know it.
 */
function useScheme(root: RefObject<HTMLElement | null>, colorScheme?: string) {
  const [scheme, setScheme] = useState<Scheme | null>(null);
  useLayoutEffect(() => {
    const element = root.current;
    if (!element) return;
    const read = () =>
      setScheme(getComputedStyle(element).outlineColor === 'rgb(0, 0, 0)' ? 'dark' : 'light');
    read();
    const ancestors = new MutationObserver(read);
    for (let node = element.parentElement; node; node = node.parentElement) {
      ancestors.observe(node, { attributes: true });
    }
    const system = window.matchMedia('(prefers-color-scheme: dark)');
    system.addEventListener('change', read);
    return () => {
      ancestors.disconnect();
      system.removeEventListener('change', read);
    };
  }, [root, colorScheme]);
  return scheme;
}

/*
 * The barcode: bar widths in units, each bar followed by a 1.6-unit gap, all 35 units tall. The
 * whole pattern, trailing gap included, is scaled to the drawing's height and centred. It is
 * decoration and encodes nothing.
 */
const BARS = [
  3, 1, 4, 2, 1, 3, 1, 5, 2, 1, 4, 2, 1, 3, 5, 1, 3, 2, 4, 1, 3, 1, 4, 2, 3, 1, 5, 2, 1, 4, 2, 1, 3,
  2, 4, 1,
];
const BARCODE = (() => {
  let x = 0;
  let path = '';
  for (const bar of BARS) {
    path += `M${x} 0h${bar}v35h-${bar}z`;
    x = Math.round((x + bar + 1.6) * 10) / 10;
  }
  return { path, width: x };
})();

export function ReceiptPrinter({
  items = SAMPLE_ITEMS,
  merchant = 'Fernway Store',
  tagline = 'Curated Component Catalog',
  orderNumber = '#4821',
  paymentMethod = 'Card ···· 4242',
  footnote = 'Auth #99824 · Fernway Ecosystem',
  currency = 'USD',
  autoPrint = true,
  printDuration = 2.4,
  autoplay = true,
  onPrintComplete,
  defaultPaper,
  defaultState,
  colorScheme,
  className = '',
}: {
  /** The receipt's lines, in order. A line's total is its price times its quantity. */
  items?: ReceiptItem[];
  /** The shop's name: in capitals at the top, and the first line of the printed copy. */
  merchant?: string;
  /** The line under the shop's name. */
  tagline?: string;
  /** Shown as given ("#4821"), copied as given; the barcode's caption drops its first "#". */
  orderNumber?: string;
  /** How it was paid, for display only: nothing is charged. */
  paymentMethod?: string;
  /** The small print at the foot of the receipt, in capitals. */
  footnote?: string;
  /** A three-letter currency code: every amount is formatted in it, US-English style. */
  currency?: string;
  /**
   * Print the first receipt by itself once the printer is seen (with autoplay off it shows
   * printed from the start); false leaves the window empty until Replay or Print.
   */
  autoPrint?: boolean;
  /** How long a print takes, in seconds (at least 0.2). */
  printDuration?: number;
  /** Play the demonstration while visible and untouched; false shows the receipt printed, still. */
  autoplay?: boolean;
  /** Called each time a feed finishes, and when Replay or Print prints at once (reduced motion). */
  onPrintComplete?: () => void;
  /** The paper to begin with, as if picked on the swatches; without it, the scheme's. */
  defaultPaper?: Paper;
  /**
   * Opens still in a given state, with the demonstration off and autoPrint ignored: 'idle' (the
   * window empty), 'feeding' (the sheet held partway out, lights on, until Replay) or 'printed'.
   */
  defaultState?: Phase;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Extra class for the root, the backdrop. */
  className?: string;
}) {
  const { ref: root, playing: unattended, take } = useAutoplay<HTMLDivElement>(6000);
  // The demonstration plays only when allowed, on screen and untouched.
  // A state to open in turns the demonstration off, and decides alone whether anything is printed.
  const playing = autoplay && !defaultState && unattended;
  const printsItself = autoPrint && !defaultState;
  // Without the demonstration nothing prints by itself, so a receipt due to print shows printed.
  const [phase, setPhase] = useState<Phase>(
    defaultState ?? (printsItself && !autoplay ? 'printed' : 'idle'),
  );
  /** Counts the sheets fed: each print mounts a fresh one. */
  const [sheet, setSheet] = useState(0);
  /** The paper picked on the swatches, or defaultPaper; until then, the colour scheme's. */
  const [picked, setPicked] = useState<Paper | undefined>(defaultPaper);
  const scheme = useScheme(root, colorScheme);
  const paper = picked ?? scheme ?? (colorScheme === 'dark' ? 'dark' : 'light');
  const [copied, setCopied] = useState(false);
  const [message, setMessage] = useState('');
  /** Enough of it on screen, in a visible tab: every animation waits otherwise. */
  const [seen, setSeen] = useState(false);
  const [reduced, setReduced] = useState(false);

  /** The phase as of its latest change, for timers and handlers that run before a render. */
  const phaseNow = useRef<Phase>(phase);
  /** Whether the feed under way is the reader's: only theirs are announced. */
  const readerFeed = useRef(false);
  const copyTimer = useRef(0);
  const printFrame = useRef<HTMLIFrameElement | null>(null);

  const money = useMemo(
    () => new Intl.NumberFormat('en-US', { style: 'currency', currency }),
    [currency],
  );
  const lines = items.map((item) => {
    const quantity = item.quantity ?? 1;
    return {
      name: quantity > 1 ? `${quantity} × ${item.name}` : item.name,
      description: item.description,
      amount: item.price * quantity,
    };
  });
  const total = money.format(lines.reduce((sum, line) => sum + line.amount, 0));
  const ready = `Receipt ${orderNumber} is ready. Total ${total}.`;
  const duration = Math.max(0.2, Number.isFinite(printDuration) ? printDuration : 2.4);

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

  const move = useCallback((next: Phase) => {
    phaseNow.current = next;
    setPhase(next);
  }, []);

  /** Feeds a fresh receipt from the top; under reduced motion it is simply printed. */
  const feed = (byReader: boolean) => {
    window.clearTimeout(copyTimer.current);
    setCopied(false);
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      move('printed');
      if (byReader) {
        announce(ready);
        onPrintComplete?.();
      }
      return;
    }
    readerFeed.current = byReader;
    move('feeding');
    setSheet((count) => count + 1);
    if (byReader) announce(`Printing receipt ${orderNumber}.`);
  };

  /** The sheet came to rest: the printer is ready. */
  const finish = (event: AnimationEvent<HTMLElement>) => {
    if (event.target !== event.currentTarget || phaseNow.current !== 'feeding') return;
    move('printed');
    if (readerFeed.current) announce(ready);
    onPrintComplete?.();
  };

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(orderNumber);
    } catch {
      announce(`Could not copy automatically. Order number: ${orderNumber}.`);
      return;
    }
    setCopied(true);
    window.clearTimeout(copyTimer.current);
    copyTimer.current = window.setTimeout(() => setCopied(false), COPIED_FOR);
    announce('Order number copied.');
  };

  /** Opens the print dialog for the receipt's text alone, in a hidden frame of its own. */
  const printCopy = () => {
    // One frame at most: one a browser never reported closing goes when the next print opens.
    printFrame.current?.remove();
    const frame = document.createElement('iframe');
    frame.setAttribute('aria-hidden', 'true');
    frame.tabIndex = -1;
    frame.style.cssText = 'position:fixed;right:0;bottom:0;width:0;height:0;border:0';
    document.body.append(frame);
    printFrame.current = frame;
    const view = frame.contentWindow;
    const doc = frame.contentDocument;
    if (!view || !doc?.head || !doc.body) {
      frame.remove();
      printFrame.current = null;
      announce('Print preview is unavailable.');
      return;
    }
    doc.title = `Receipt ${orderNumber}`;
    const style = doc.createElement('style');
    style.textContent = [
      '@page { margin: 12mm }',
      'body { margin: 24px }',
      'pre { margin: 0; font: 12px/1.8 monospace; white-space: pre-wrap; overflow-wrap: anywhere }',
    ].join('\n');
    doc.head.append(style);
    // Text only: whatever the props hold is shown as written, never read as markup.
    const text = doc.createElement('pre');
    text.textContent = [
      merchant,
      `ORDER: ${orderNumber}`,
      `DATE: ${DATE}`,
      `PAYMENT: ${paymentMethod}`,
      '',
      ...lines.map((line) => `${line.name} — ${money.format(line.amount)}`),
      '',
      `TOTAL: ${total}`,
      '',
      THANKS,
    ].join('\n');
    doc.body.append(text);
    view.addEventListener(
      'afterprint',
      () => {
        frame.remove();
        if (printFrame.current === frame) printFrame.current = null;
      },
      { once: true },
    );
    view.focus();
    view.print();
  };

  // Whether anyone can see the printer: at least 15% of it on screen, in a visible tab.
  useEffect(() => {
    const element = root.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setSeen(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting && entry.intersectionRatio >= SEEN_AT;
        update();
      },
      { threshold: [0, SEEN_AT] },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, [root]);

  useEffect(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)');
    const update = () => setReduced(query.matches);
    update();
    query.addEventListener('change', update);
    return () => query.removeEventListener('change', update);
  }, []);

  // Under reduced motion nothing moves: once seen, a receipt due to print shows printed, and a
  // feed under way ends at once. Both silently, without the completion callback.
  useEffect(() => {
    if (!reduced) return;
    if (phaseNow.current === 'feeding' || (seen && printsItself && phaseNow.current === 'idle')) {
      move('printed');
    }
  }, [reduced, seen, printsItself, move]);

  // Turning the demonstration off later leaves the receipt printed too.
  useEffect(() => {
    if (!autoplay && printsItself && phaseNow.current === 'idle') move('printed');
  }, [autoplay, printsItself, move]);

  // The demo: the first print shortly after the printer is seen (autoPrint's to make, or else the
  // reader's), then one every 11 s. It waits while the printer is hidden or the reader uses it,
  // and counts from zero again when it resumes. A repeat never cuts a feed short.
  const demo = playing && seen;
  const started = phase !== 'idle';
  const autoFeed = useEffectEvent(() => {
    if (phaseNow.current !== 'feeding') feed(false);
  });
  useEffect(() => {
    if (!demo || (!started && !printsItself)) return;
    let timer = window.setTimeout(
      function next() {
        autoFeed();
        timer = window.setTimeout(next, REPEAT);
      },
      started ? REPEAT : FIRST_PRINT,
    );
    return () => window.clearTimeout(timer);
  }, [demo, started, printsItself]);

  // Nothing is left behind once the printer is gone.
  useEffect(
    () => () => {
      window.clearTimeout(copyTimer.current);
      printFrame.current?.remove();
    },
    [],
  );

  const feeding = phase === 'feeding';
  // The first sheet of defaultState="feeding" stands still partway out, lights paused, until a
  // new one is fed.
  const held = defaultState === 'feeding' && sheet === 0;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-phase={phase}
      data-paper={picked}
      data-color-scheme={colorScheme}
      data-paused={!seen || held || undefined}
      style={{ '--print-duration': `${duration}s` } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.assembly}>
        <section className={styles.banner} aria-label="Payment status" data-part="banner">
          <span className={styles.badge} data-part="badge">
            <TickIcon />
          </span>
          <div className={styles.headline}>
            <p className={styles.title}>Payment Complete</p>
            <p className={styles.subtitle}>Receipt has been issued</p>
          </div>
          <p className={styles.chip} data-part="chip">
            <span className={styles.chipDot} aria-hidden="true" />
            {CHIP[phase]}
          </p>
        </section>

        <div className={styles.printer} data-part="printer">
          <div className={styles.controls}>
            <span className={styles.model}>
              <PrinterIcon />
              POS-8000
            </span>
            <span className={styles.pill} data-part="pill">
              <span className={styles.pillDot} aria-hidden="true" />
              {PILL[phase]}
            </span>
          </div>
          <div className={styles.slot} aria-hidden="true" data-part="slot" />
        </div>

        <div className={styles.window} data-part="window">
          <article
            key={sheet}
            className={styles.paper}
            aria-label={`Receipt ${orderNumber}`}
            data-part="paper"
            style={held ? { animationDelay: `${-duration * HELD_AT}s` } : undefined}
            onAnimationEnd={finish}
          >
            <header className={styles.head}>
              <svg className={styles.glyph} viewBox="0 0 22 22" aria-hidden="true">
                <path d="M11 5.8v12.2M4.9 11.9h12.2M6.69 7.59l8.62 8.62M15.31 7.59l-8.62 8.62" />
              </svg>
              <h3 className={styles.merchant}>{merchant}</h3>
              <p className={styles.tagline}>{tagline}</p>
            </header>

            <dl className={styles.details}>
              <div>
                <dt>Order no:</dt>
                <dd>{orderNumber}</dd>
              </div>
              <div>
                <dt>Date:</dt>
                <dd>{DATE}</dd>
              </div>
              <div>
                <dt>Payment:</dt>
                <dd>{paymentMethod}</dd>
              </div>
            </dl>

            <table className={styles.items}>
              <thead>
                <tr>
                  <th scope="col">Item</th>
                  <th scope="col">Price</th>
                </tr>
              </thead>
              <tbody>
                {lines.map((line, index) => (
                  <tr key={index} data-part="line">
                    <td>
                      {line.name}
                      {line.description && (
                        <span className={styles.note} data-part="note">
                          {line.description}
                        </span>
                      )}
                    </td>
                    <td>{money.format(line.amount)}</td>
                  </tr>
                ))}
              </tbody>
            </table>

            <dl>
              <div className={styles.subtotal}>
                <dt>Subtotal:</dt>
                <dd>{total}</dd>
              </div>
              <div className={styles.total} data-part="total">
                <dt>Total:</dt>
                <dd>{total}</dd>
              </div>
            </dl>

            <div className={styles.code} data-part="barcode">
              <svg
                viewBox={`0 0 ${BARCODE.width} 35`}
                fill="currentColor"
                fillOpacity="0.85"
                aria-hidden="true"
              >
                <path d={BARCODE.path} />
              </svg>
              <p className={styles.caption}>{`* ${orderNumber.replace('#', '')} *`}</p>
            </div>

            <footer className={styles.foot}>
              <p>{THANKS}</p>
              <p>
                <small className={styles.auth}>{footnote}</small>
              </p>
            </footer>

            <span className={styles.notch} data-side="left" aria-hidden="true" />
            <span className={styles.notch} data-side="right" aria-hidden="true" />
          </article>
        </div>

        <div className={styles.actions} data-part="actions">
          <button
            type="button"
            className={styles.action}
            data-part="replay"
            aria-label="Replay receipt printing"
            onClick={() => feed(true)}
          >
            <ReplayIcon />
            Replay
          </button>
          <button
            type="button"
            className={styles.action}
            data-part="copy"
            aria-label="Copy order number"
            onClick={copy}
          >
            {copied ? <TickIcon /> : <CopyIcon />}
            {copied ? 'Copied' : 'Copy order'}
          </button>
          {/* While feeding it stays focusable but inert, so keyboard focus is never lost. */}
          <button
            type="button"
            className={styles.action}
            data-part="print"
            aria-label="Print receipt"
            aria-disabled={feeding || undefined}
            onClick={() => {
              if (phaseNow.current === 'idle') feed(true);
              else if (phaseNow.current === 'printed') printCopy();
            }}
          >
            <PrinterIcon />
            Print
          </button>
        </div>

        <div className={styles.papers} role="group" aria-label="Receipt paper" data-part="swatches">
          {PAPERS.map((name) => (
            <button
              key={name}
              type="button"
              className={styles.swatch}
              data-part="swatch"
              data-paper={name}
              aria-pressed={paper === name}
              aria-label={`${name} receipt paper`}
              onClick={() => setPicked(name)}
            />
          ))}
        </div>
      </div>

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

### receipt-printer/receipt-printer.module.css

```css
/* Receipt printer (receipt-printer.tsx): a backdrop, light or near-black, holding a centred
   column, at most 360px wide: the payment banner, the printer, the paper window, the buttons and
   the paper swatches. The window is as tall as the receipt at rest and clips it, so the receipt
   only ever moves inside it and the component keeps one height in every state. `data-phase` on
   the root (idle, feeding, printed) drives the lights, the buzz and the feed; `data-paper` the
   paper picked, on the swatches or by defaultPaper; `data-paused` freezes every animation while
   the printer is out of sight.

   The backdrop, the buttons, the swatches and the paper until one is picked are light-dark(light,
   dark) tokens: the component takes the color-scheme it inherits from the page, or the one its
   colorScheme prop sets (data-color-scheme). The banner and the printer are black objects on
   both. */

.root {
  --sans: 'Library Inter', Inter, Arial, sans-serif;
  --mono: 'Library Mono', ui-monospace, monospace;
  --focus: #5c91ee;
  --backdrop: #f8f8f8;
  /* The buttons under the window, at rest and under the pointer. */
  --button: #fff;
  --button-edge: #e5e5e5;
  --button-ink: #777;
  --button-edge-hover: #bbb;
  --button-ink-hover: #222;
  /* The dark paper's swatch: its edge, lighter at night so the disc stands out on the backdrop. */
  --swatch-dark-edge: #202020;
  /* The paper: light on a light page and dark on a dark one, until the reader picks one. */
  --paper: #f9f9f8;
  --ink: #333;
  --edge: #e4e4e2;
  --rule: rgb(136 136 136 / 0.27);
  /* For the script: a colour the root never shows (it has no outline), white in light and black
     in dark, which tells the swatches the scheme the root is drawn in. */
  outline-color: #fff;
  position: relative;
  isolation: isolate;
  min-width: 0;
  padding: 24px 12px;
  border-radius: 20px;
  background: var(--backdrop);
  color: #111;
  font: 400 12px/1.5 var(--sans);
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --backdrop: light-dark(#f8f8f8, #0a0a0a);
    --button: light-dark(#fff, #141414);
    --button-edge: light-dark(#e5e5e5, #2b2b2b);
    --button-ink: light-dark(#777, #a3a3a3);
    --button-edge-hover: light-dark(#bbb, #616161);
    --button-ink-hover: light-dark(#222, #f2f2f2);
    --swatch-dark-edge: light-dark(#202020, #616161);
    --paper: light-dark(#f9f9f8, #161616);
    --ink: light-dark(#333, #d5d5d5);
    --edge: light-dark(#e4e4e2, #333);
    outline-color: light-dark(#fff, #000);
    color: light-dark(#111, #ededed);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}
/* The paper picked, on the swatches or by defaultPaper: the same whatever the scheme. */
.root[data-paper='light'] {
  --paper: #f9f9f8;
  --ink: #333;
  --edge: #e4e4e2;
}
.root[data-paper='cream'] {
  --paper: #fcf5e8;
  --ink: #504231;
  --edge: #e7ddca;
}
.root[data-paper='dark'] {
  --paper: #161616;
  --ink: #d5d5d5;
  --edge: #333;
}
.root :where(p, h3, dl, dd) {
  margin: 0;
}

.assembly {
  width: min(360px, 100%);
  margin: 0 auto;
}

/* The payment banner: a badge, two lines, and a chip that follows the printer's state. */
.banner {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 13px 12px;
  border: 1px solid #222;
  border-radius: 16px;
  background: #0e0e0e;
  box-shadow: 0 2px 5px rgb(0 0 0 / 0.067);
}
.badge {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border: 1px solid rgb(55 107 86 / 0.35);
  border-radius: 50%;
  background: rgb(18 48 37 / 0.5);
  color: #80c4a0;
}
.badge svg {
  width: 15px;
  height: 15px;
}
.headline {
  flex: 1;
  min-width: 0;
}
.title {
  font-size: 12px;
  font-weight: 600;
  line-height: 1.3;
  color: #fafafa;
}
.subtitle {
  margin-top: 2px;
  font-size: 10px;
  line-height: 12.4px;
  color: #999;
}
.chip {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-shrink: 0;
  white-space: nowrap;
  font: 9px/12px var(--mono);
  color: #555;
}
.chipDot,
.pillDot {
  flex-shrink: 0;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #4d4d4d;
}

/* The printer: a dark body above the paper window, so its shadow falls on the paper. */
.printer {
  position: relative;
  z-index: 2;
  margin-top: 13px;
  padding: 14px 14px 12px;
  border: 1px solid #282828;
  border-radius: 18px;
  background: linear-gradient(#171717, #0c0c0c);
  box-shadow:
    0 16px 28px rgb(0 0 0 / 0.33),
    inset 0 1px 1px rgb(255 255 255 / 0.09);
}
/* A highlight along the top edge, fading out at both ends. */
.printer::before {
  content: '';
  position: absolute;
  top: 0;
  left: 18px;
  right: 18px;
  height: 1px;
  background: linear-gradient(90deg, transparent, rgb(255 255 255 / 0.14), transparent);
}
.controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 10px;
  padding-bottom: 10px;
  border-bottom: 1px solid #202020;
}
.model {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  font: 10px/14px var(--mono);
  color: #bbb;
}
.model svg {
  flex-shrink: 0;
  width: 14px;
  height: 14px;
}
.pill {
  display: flex;
  align-items: center;
  gap: 5px;
  flex-shrink: 0;
  padding: 3px 6px;
  border: 1px solid #242424;
  border-radius: 12px;
  background: #050505;
  font: 8px/10px var(--mono);
  color: #666;
  text-transform: uppercase;
  white-space: nowrap;
}
/* The slot: decoration only, the paper comes out from under the body's lip. */
.slot {
  position: relative;
  height: 11px;
  border: 1px solid #222;
  border-radius: 999px;
  background: #030303;
  box-shadow: inset 0 2px 4px rgb(0 0 0 / 0.6);
}
.slot::after {
  content: '';
  position: absolute;
  top: 4px;
  left: 12.5%;
  right: 12.5%;
  height: 1px;
  background: linear-gradient(90deg, transparent, #272727, transparent);
}

/* The lights: grey while idle, pulsing while feeding, steady green once printed. */
.root[data-phase='feeding'] .chipDot {
  background: #dbb042;
  animation: pulse 800ms ease-in-out infinite;
}
.root[data-phase='printed'] .chipDot {
  background: #56a57e;
}
.root[data-phase='feeding'] .pillDot {
  background: #62ca9c;
  box-shadow: 0 0 8px rgb(68 216 149 / 0.65);
  animation: pulse 800ms ease-in-out infinite;
}
.root[data-phase='printed'] .pillDot {
  background: #50a27d;
  box-shadow: 0 0 5px rgb(80 183 132 / 0.4);
}
/* The motor's buzz, under a pixel: felt more than seen. */
.root[data-phase='feeding'] .printer {
  animation: buzz 180ms ease infinite;
}

/* The paper window: tucked under the printer's lip, it clips the receipt and its shadow. At rest
   the paper's top edge sits 3.4px under the lip, out of sight. */
.window {
  position: relative;
  z-index: 1;
  min-height: 430px;
  margin-top: -8.4px;
  padding: 5px 12px 35px;
  overflow: hidden;
  overflow: clip;
}

/* The receipt: at rest when printed, hidden above the window while idle. */
.paper {
  --muted: color-mix(in srgb, var(--ink) 55%, transparent);
  --soft: color-mix(in srgb, var(--ink) 60%, transparent);
  position: relative;
  padding: 21px 20px 22px;
  border: 1px solid var(--edge);
  border-radius: 0 0 3px 3px;
  background: var(--paper);
  color: var(--ink);
  box-shadow: 0 15px 22px rgb(0 0 0 / 0.125);
  font: 400 10px/1.55 var(--mono);
  overflow-wrap: anywhere;
  transform: translateY(-108%);
}
.root[data-phase='printed'] .paper {
  transform: none;
}
.root[data-phase='feeding'] .paper {
  animation: feed var(--print-duration, 2.4s) cubic-bezier(0.16, 1, 0.3, 1) both;
}
.root[data-paper='dark'] .paper {
  color-scheme: dark;
}
.root[data-paused] :is(.printer, .chipDot, .pillDot, .paper) {
  animation-play-state: paused;
}
/* The serrated bottom edge: ten teeth in the paper's colour, over its bottom border. */
.paper::after {
  content: '';
  position: absolute;
  left: -1px;
  right: -1px;
  bottom: -10px;
  height: 12px;
  background: var(--paper);
  clip-path: polygon(
    0 0,
    100% 0,
    100% 3px,
    95% 100%,
    90% 3px,
    85% 100%,
    80% 3px,
    75% 100%,
    70% 3px,
    65% 100%,
    60% 3px,
    55% 100%,
    50% 3px,
    45% 100%,
    40% 3px,
    35% 100%,
    30% 3px,
    25% 100%,
    20% 3px,
    15% 100%,
    10% 3px,
    5% 100%,
    0 3px
  );
}
/* The punched holes on both edges, the same on every paper. */
.notch {
  position: absolute;
  top: 124px;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: #151515;
  box-shadow: 0 0 0 1px rgb(0 0 0 / 0.13);
}
.notch[data-side='left'] {
  left: -7px;
}
.notch[data-side='right'] {
  right: -7px;
}

.head {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-bottom: 13px;
  border-bottom: 1px dashed var(--rule);
  text-align: center;
}
.glyph {
  display: block;
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 0.9;
}
.merchant {
  margin-top: 6px;
  font-size: 12px;
  font-weight: 700;
  line-height: 1.5;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.tagline {
  margin-top: 2px;
  font-size: 8px;
  color: var(--soft);
}

.details {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 14px 0;
  border-bottom: 1px dashed var(--rule);
  font-size: 9px;
}
.details div,
.subtotal,
.total {
  display: flex;
  justify-content: space-between;
  gap: 7px;
}
.details dt {
  flex-shrink: 0;
  color: var(--muted);
  text-transform: uppercase;
}
.details dd {
  min-width: 0;
  text-align: right;
}

.items {
  width: 100%;
  margin: 12px 0;
  border-collapse: collapse;
}
.items th {
  padding: 0 0 8px;
  font-size: 8px;
  font-weight: 500;
  color: var(--muted);
  text-align: left;
  text-transform: uppercase;
}
.items td {
  padding: 4px 0 3px;
  vertical-align: top;
}
.items :is(th, td):last-child {
  padding-left: 12px;
  text-align: right;
  white-space: nowrap;
}
.note {
  display: block;
  margin-top: 4px;
  font-size: 8px;
  font-style: italic;
  color: var(--muted);
}

.subtotal {
  padding-top: 10px;
  border-top: 1px dashed var(--rule);
  font-size: 9px;
  color: var(--muted);
  text-transform: uppercase;
}
.total {
  align-items: baseline;
  margin-top: 6px;
  padding: 8px 0 11px;
  border-top: 1px solid rgb(136 136 136 / 0.2);
  border-bottom: 1px dashed var(--rule);
  font-weight: 700;
}
.total dt {
  font-size: 12px;
  text-transform: uppercase;
}
.total dd {
  font-size: 14px;
  white-space: nowrap;
}

.code {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 14px 0 3px;
}
.code svg {
  display: block;
  width: 190px;
  max-width: 88%;
  height: 30px;
}
.caption {
  margin-top: 3px;
  font-size: 8px;
  letter-spacing: 0.1em;
  color: var(--soft);
}

.foot {
  margin-top: 9px;
  font-size: 9px;
  text-align: center;
}
.foot p:first-child {
  color: color-mix(in srgb, var(--ink) 70%, transparent);
}
.auth {
  font-size: 7px;
  color: color-mix(in srgb, var(--ink) 30%, transparent);
  text-transform: uppercase;
}

/* The buttons, right under the window, and the paper swatches. No transitions anywhere. */
.actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 9px;
}
.action {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 10px;
  border: 1px solid var(--button-edge);
  border-radius: 9px;
  background: var(--button);
  color: var(--button-ink);
  font: 400 10px/15px var(--sans);
  white-space: nowrap;
  cursor: pointer;
}
.action svg {
  flex-shrink: 0;
  width: 13px;
  height: 13px;
}
@media (hover: hover) {
  .action:not([aria-disabled]):hover {
    border-color: var(--button-edge-hover);
    color: var(--button-ink-hover);
  }
}
.action[aria-disabled] {
  opacity: 0.4;
  cursor: wait;
}

.papers {
  display: flex;
  justify-content: center;
  gap: 9px;
  margin-top: 16px;
}
.swatch {
  position: relative;
  width: 15px;
  height: 15px;
  padding: 0;
  border: 1px solid #ddd;
  border-radius: 50%;
  background: #f9f9f8;
  cursor: pointer;
}
/* A larger target than the dot itself. */
.swatch::after {
  content: '';
  position: absolute;
  inset: -5px;
  border-radius: 50%;
}
.swatch[data-paper='cream'] {
  background: #eadbbd;
}
.swatch[data-paper='dark'] {
  border-color: var(--swatch-dark-edge);
  background: #202020;
}
.swatch[aria-pressed='true'] {
  outline: 1px solid #888;
  outline-offset: 3px;
}
.action:focus-visible,
.swatch:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}

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

@keyframes feed {
  from {
    transform: translateY(-108%);
    opacity: 0.6;
  }
  to {
    transform: none;
    opacity: 1;
  }
}
@keyframes buzz {
  0%,
  100% {
    transform: translate(0, 0);
  }
  25% {
    transform: translate(-0.6px, -0.2px);
  }
  60% {
    transform: translate(0.6px, 0.2px);
  }
  80% {
    transform: translate(-0.4px, 0);
  }
}
@keyframes pulse {
  50% {
    opacity: 0.4;
  }
}

/* Nothing moves: the receipt always shows at rest, and nothing buzzes or pulses. */
@media (prefers-reduced-motion: reduce) {
  .root[data-phase] .paper {
    transform: none;
    animation: none;
  }
  .root[data-phase] :is(.printer, .chipDot, .pillDot) {
    animation: none;
  }
}
```

### receipt-printer/icons.tsx

```tsx
import type { ReactNode } from 'react';

/*
 * The receipt printer's line icons, drawn on a 24-unit grid with a 1.5-unit round stroke in the
 * current text colour; the stylesheet sets their size. All decorative: the words beside them, or
 * the button around them, carry the meaning.
 */

function Icon({ children }: { children: ReactNode }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {children}
    </svg>
  );
}

export function TickIcon() {
  return (
    <Icon>
      <path d="M5 12l4 4L19 6" />
    </Icon>
  );
}

/** A desktop printer: the tray at the back, the body, and a sheet coming out of the front. */
export function PrinterIcon() {
  return (
    <Icon>
      <path d="M6 8V3h12v5" />
      <path d="M6 16H3V9h18v7h-3" />
      <rect x="6" y="13" width="12" height="8" />
    </Icon>
  );
}

/** A circular arrow, open on the left, its head pointing back to where it started. */
export function ReplayIcon() {
  return (
    <Icon>
      <path d="M4 10a8 8 0 1 1 1 7" />
      <path d="M4 4v6h6" />
    </Icon>
  );
}

/** Two sheets: the one in front whole, the one behind showing at its top-left corner. */
export function CopyIcon() {
  return (
    <Icon>
      <rect x="8" y="8" width="12" height="12" rx="2" />
      <path d="M16 8V4H4v12h4" />
    </Icon>
  );
}
```

### receipt-printer/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 };
}
```