Buttons

Print Button

A print trigger that prepares the page before the dialog opens — a cancellable async hook, a print scope class, and a restore raced between afterprint, the print media query and a timeout.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { LoaderCircle, Printer, RotateCcw } from "lucide-react"
import { cn } from "@/lib/utils"

/** Label layers fade up as the run advances. React 19 hoisted <style>, deduped by href. */
const KEYFRAMES = `@keyframes pb-state-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}`

export type PrintStatus = "idle" | "preparing" | "printing" | "error"

const STATUSES: readonly PrintStatus[] = ["idle", "preparing", "printing", "error"]

export type PrintVariant = "default" | "outline" | "ghost"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/print-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PrintButton" component with lucide-react
icons and a cn() className merger, and nothing else: the print dialog is a
browser API, not a library. The point of the component is the two things that
surround window.print() — getting the page ready first, and putting it back
afterwards even when the browser never tells you the dialog closed.

Contract
- Export a forwardRef <button> extending React.ButtonHTMLAttributes<HTMLButtonElement>
  plus:
    onBeforePrint?: (context: { signal: AbortSignal }) => void | Promise<unknown>
    onAfterPrint?: () => void
    onPrepareError?: (error: unknown) => void
    onStatusChange?: (status: PrintStatus) => void
    printScope?: string        space-separated classes added to <html>
    prepareTimeout?: number    default 10000; 0 waits forever
    restoreTimeout?: number    default 1000; 0 waits for the events only
    print?: () => void         default () => window.print()
    label / preparingLabel / printingLabel / errorLabel / cancelLabel: string
    cancellable?: boolean      default true
    iconOnly?: boolean
    variant?: "default" | "outline" | "ghost"
    disabled?: boolean
- PrintStatus = "idle" | "preparing" | "printing" | "error". The status is
  INTERNAL, unlike a download button: nobody outside owns the browser's dialog,
  and the events that end a run arrive on window. onStatusChange mirrors it out
  for a caption or a sibling control; it is a report, not a control.
- `print` is the seam that makes the component testable and reusable: hand in
  `() => frame.contentWindow?.print()` and the same preparation, scope and
  restore drive an off-screen document instead of the page.
- The ref lands on the button. className merges onto the button too, so
  `className="w-full"` means what a caller expects.

Behavior
- One run, start to finish: lock → prepare → scope → paint → dialog → restore.
- The lock is a ref holding a monotonic token, read AND written in the same
  synchronous turn as the click. A useState flag still holds its old value when
  a second click lands in the same tick, and two runs would race to open two
  dialogs and undo each other's scope.
- Preparation. Call onBeforePrint({ signal }) and race its promise against three
  things: an abort (Cancel or unmount), a prepareTimeout, and its own settlement.
  Build the promise INSIDE the try so a hook that throws synchronously is a
  refusal rather than an exception escaping the click handler. Promise.race
  attaches a handler to every input, so a hook that rejects after the timeout
  already won cannot surface as an unhandled rejection. Classify by the SIGNAL,
  not by the rejection value: a hook that rejects with its own "cancelled" error
  when aborted must not be reported as a failure.
- Refusals. A rejection or a timeout sets status "error", hands the raw error to
  onPrepareError and never opens the dialog. The button becomes "Try again" in
  destructive ink and keeps saying so until the reader acts — success may expire,
  a failure may not. The reason rides on aria-label and the live region, not in
  a paragraph the button has no room for; onPrepareError is where a visible
  message belongs.
- The scope class lands only AFTER preparation succeeds — a refused run must
  never flash print-only layout at the reader — and only the classes the page
  did not already carry are added, so removal never takes someone else's.
- The paint gate. window.print() blocks in most engines, and a dialog opened in
  the same tick as a setState snapshots the page BEFORE React has committed it.
  So: apply the scope, then await two requestAnimationFrames (the first ends the
  commit, the second guarantees pixels), then print. Both frames are cancellable
  and the cancel path resolves the promise anyway, so the awaiting run reaches
  its staleness check instead of parking a closure forever.
- Restoring is a race with three entrants, first one wins, all guarded by the
  same token so the losers are no-ops:
    1. the afterprint event,
    2. the "print" media query going false — Safari's reliable tell, and the only
       signal some engines emit at all,
    3. a restoreTimeout, armed only once print() has RETURNED, because a browser
       that blocked inside print() has by definition already closed its dialog.
  The timeout re-arms itself while the media query still matches (the dialog is
  demonstrably open; restoring under it would be worse), with a hard ceiling so a
  print context that never ends cannot keep the page hostage forever.
- Restore = remove exactly the classes this run added, then call onAfterPrint.
  onAfterPrint runs exactly ONCE for every run that started — printed, cancelled,
  timed out or failed alike. A hook that half-expanded the page always gets its
  counterpart, which is the difference between an undo hook and a hope.
- Keyboard. Both controls are real <button type="button">s, so Enter and Space
  activate them natively — do not re-implement key handling for a button, and do
  not swallow the keys either. The tab order is trigger → Cancel (only while
  preparing) → whatever follows; the trigger keeps its place in that order even
  when inert, which is the whole reason it is not natively disabled. Escape
  belongs to the browser's own dialog, and no global shortcut is claimed by
  default: Cmd/Ctrl+P stays the browser's until you opt into the lever below.
- Cancel is a real control, not a label: while preparing, a ghost button beside
  the trigger aborts the run. It unmounts the instant the run leaves "preparing"
  — by cancel, by failure, or by preparation simply finishing — so focus is
  handed to the trigger FIRST whenever it is standing there. Focus must never be
  dropped on <body>.
- Focus after the dialog: some browsers return focus to <body>. Rescue it to the
  trigger, but only when the trigger owned focus when the run began — otherwise a
  print started ten seconds ago yanks a reader back from wherever they went.
- disabled is aria-disabled plus an early return, never the native attribute: the
  browser blurs a node the moment it goes disabled, and a print button goes inert
  exactly when the reader is standing on it. The guard also skips the consumer's
  own onClick, which is what the native attribute would have done.
- Cleanup, on unmount and on every exit: cancel both frames, clear the fallback
  timer, remove the afterprint and media listeners, abort the signal, take the
  scope classes off <html>, and still call onAfterPrint — a component that
  unmounts mid-print must not leave the document in print scope. An alive flag is
  SET on mount as well as cleared on unmount, because StrictMode runs the cleanup
  once before the real mount.
- Everything read after an await comes from a ref that mirrors the current props;
  a run must not report into a component that has moved on.

Rendering & styling
- Semantic tokens only. default = bg-primary / text-primary-foreground, outline =
  border + bg-background, ghost = bare until hover:bg-accent. A refusal repaints
  the ink only (bg-destructive/15 + text-destructive) and keeps the variant's
  border and shadow, so the shape does not change when the state does.
- All four labels stack into one CSS grid cell; the inactive ones stay `invisible`
  but keep their natural width, so the button is as wide as its widest state and
  cannot jump from "Print" to "Preparing…" under the cursor. iconOnly drops the
  words and squares the box; the accessible name still follows the state.
- The button's subtree is presentational, so the polite role="status" region is a
  SIBLING of the button, inside a display:contents wrapper that adds no box. It is
  always mounted — a live region that arrives together with its text is not
  announced — and it speaks the phase ("Preparing the page for printing.", "The
  print dialog has closed and the page has been restored."), never the mechanics.
- aria-busy while preparing or printing, cursor-progress instead of pretending to
  be idle, aria-label restating the state (and the refusal reason when there is
  one), focus-visible:ring-2 ring-ring on both buttons.
- The button carries data-status="idle | preparing | printing | error", so a host
  stylesheet can react to the run without reading a single prop.
- Motion is decoration: a 200ms fade-up on the active label layer and a spinning
  loader while preparing, both behind motion-reduce. With motion off the label
  swaps instantly and the run is identical.

Customization levers
- printScope is the whole styling contract with the page: pick a class, write
  `@media print { .printing aside, .printing nav { display: none } }` in your own
  stylesheet, and the component never needs to know what printing looks like.
  Several classes may be passed at once for a per-document scope.
- prepareTimeout is a budget for someone else's promise — raise it when
  preparation genuinely fetches pages, lower it when the hook only expands DOM.
  restoreTimeout is about how long the page may stay in print scope after a
  browser goes quiet; 0 trusts the events alone.
- Swap the run's contents, not its shape: an onBeforePrint that awaits
  document.fonts.ready, decodes every <img> below the fold, or fetches the
  remaining rows of a table is the same component with a different hook.
- `print` retargets the whole machine at an off-screen iframe, a child window, or
  a test double — nothing else in the component assumes the page being printed is
  this one.
- Keyboard bypass: Cmd/Ctrl+P prints an unprepared page, because that path never
  reaches this button. Close it by adding a capture-phase keydown listener on
  window that preventDefault()s the shortcut and calls the same handler — the run
  is one function, so nothing else changes.
- Copy is the i18n surface: five label props, kept short enough that the widest
  one still fits the min-w-36 floor. Drop cancellable in a layout where a
  transient second child would reflow (a fixed grid cell, an absolutely
  positioned corner); the prepareTimeout still guarantees an exit.
- Density: min-w-36 px-4 py-2.5 for a page header, size-10 iconOnly for a
  toolbar. Nothing is measured in JavaScript, so sizing is a class away.

Concepts

  • Prepare, then print — the dialog is the last step, not the first: the button owns an async window in which collapsed sections open, lazy images decode and a print stylesheet is scoped, and only a preparation that actually succeeded is allowed to reach window.print().
  • Restore is a race, not an eventafterprint is the happy path, the print media query going false is the one some engines emit instead, and a timeout armed after print() returns is the floor; the same token guard makes the two losers no-ops, so a browser that reports nothing still gets its page back.
  • A balanced paironAfterPrint runs exactly once for every run that started, including the cancelled, timed-out and failed ones, and even when the component unmounts mid-print; an undo hook that only fires on the happy path is how a page ends up permanently expanded.
  • The paint gateprint() blocks and snapshots whatever is on screen, so the run waits two animation frames after the scope class lands: without that pause the dialog captures the page as it was before preparation was committed.
  • A ref lock, not a state lock — the one-shot guard is read and written synchronously inside the handler, because a useState flag still holds its old value when a double click lands in the same tick, and the second run would undo the first one's scope while its dialog was still open.
  • Nothing goes inert under the readerdisabled is aria-disabled plus a handler guard, and the Cancel affordance hands focus to the trigger before it unmounts, so neither the inert state nor the end of preparation can drop the keyboard on <body>.

On This Page