{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "print-button",
  "title": "Print Button",
  "description": "Prints one region of the page — an invoice, a receipt, an order confirmation, a ticket or boarding pass, a packing slip, a report or dashboard panel, a chart, a table, a quote or estimate, an itinerary, a certificate, a prescription, a shipping label, a résumé, or a card carrying a generated QR code — instead of printing the page it happens to be sitting on. Common asks it answers: 'react print component', 'print div react', 'print only part of the page', 'print specific div react', 'window.print prints whole page', 'react-to-print alternative', 'print button shadcn', 'print invoice react', 'print receipt component', 'save as pdf button react', 'print styles missing react', 'printed div has no css', 'canvas blank when printed', 'form values empty when printed', 'background color not printing', 'print preview cuts off scrollable div', 'how to detect print finished react', '@media print react component'. shadcn/ui has nothing here at all: window.print, beforeprint, afterprint, @media print and print-color-adjust each appear exactly zero times across the whole 255KB of component source its registry serves, so this gets written inline every time. Not to be confused with pulld recovery-codes, which builds a sheet of its own out of data you hand it and is the right choice for backup codes; this one copies a region of the live page, styles and all. The inline version has two shapes and both are wrong. onClick={() => window.print()} prints the document — nav, sidebar, cookie banner — and clips a scrolling panel to whatever was scrolled into view. The fix usually reached for next is a global @media print rule that hides everything except one class, which puts a layout concern into app-wide CSS and breaks the next time the markup moves. This clones the region into an off-screen document instead, and the details that make that hold up are the ones a hand-rolled version leaves out. Styles do not come with a clone, so the page's own are carried over: link elements are re-linked by href rather than serialised, because reading cssRules on a cross-origin sheet — a font service, a CDN build — throws SecurityError and silently drops exactly the stylesheets you cannot see, while constructable adoptedStyleSheets have no href and are serialised instead. A base element is written into the head, because a srcdoc frame has no URL of its own and every relative image on the sheet would otherwise resolve against nothing. What the user typed is copied onto the clone, because value, checked and selected are properties and not attributes, so a filled-in form serialises back to the blank form it was authored as — on a sheet that looks finished. Canvases are redrawn as images, because cloneNode copies the element and not the bitmap, and a chart, a sparkline or a captured signature otherwise prints as white space; a tainted canvas loses its own picture rather than the sheet. print-color-adjust is forced on, because browsers drop background colours and a colour-coded table prints white on white. Scroll boxes are unclipped, because a panel keeps its height on paper and everything below its fold is simply absent. The frame recipe is the rest of it: 0x0 and transparent rather than display:none (a frame that is not displayed prints a blank page), srcdoc assigned before insertion so the only load event is the sheet's rather than the initial about:blank, printing on that load event because it is what waits for the copied stylesheets, a 3s bound on that wait so one unreachable CDN cannot leave the button doing nothing forever, and teardown on afterprint rather than on the next line, because print() blocks in Chrome and Firefox but returns immediately in Safari where removing the frame would cancel a dialog still open. The API: target is a ref to the region, documentTitle becomes the sheet's title and therefore the default filename under Save as PDF, pageStyle appends CSS after the built-in rules so you can override them, onBeforePrint fires before serialisation (expand rows, reveal detail), and onPrintEnd reports 'done' | 'unavailable' | 'failed'. That outcome is deliberately not 'printed': afterprint fires for a cancelled dialog exactly as it does for a finished job, and every 'mark as printed' flag built on it eventually lies, so this one refuses to claim more than the browser said. type='button' so it will not submit a surrounding form, the state is announced through an sr-only aria-live region, the icon is aria-hidden, the button disables itself while a sheet is being prepared so a second click cannot open a second dialog, and the reset timer is cleared on unmount. Nothing rendered depends on feature detection, only what the click does, so the server and the first client render agree and there is no hydration mismatch. Styled with shadcn tokens (input, accent, ring) for light and dark, className merges rather than fights, focus ring is focus-visible. One file, one lucide icon, no print library.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/print-button.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Printer } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * What the browser was willing to tell us, which is less than you would like.\n *\n * There is no event for \"paper came out of the printer\" and none for \"the dialog was cancelled\":\n * `afterprint` fires for both, so a button that settles on \"Printed!\" is guessing, and every\n * \"mark as printed\" flag built on it eventually lies. `done` means the dialog closed and nothing\n * more. `unavailable` is a browser with no printing at all — the in-app webviews that open links\n * inside social apps are the common case. `failed` is a region that could not be serialised.\n */\nexport type PrintOutcome = \"done\" | \"unavailable\" | \"failed\"\n\n/**\n * Marks the printable document's body so {@link printRegion} can tell it from a blank frame.\n *\n * Deliberately not the marker `recovery-codes` uses: the two print different things and neither\n * should ever accept the other's document.\n */\nconst PRINT_MARKER = \"data-pulld-print\"\n\n/**\n * How long an unanswered print is given before the frame is torn down anyway.\n *\n * Long on purpose. Removing the frame while a print dialog is still open cancels the job, so this\n * only ever fires when `afterprint` never arrives at all.\n */\nconst CLEANUP_MS = 60_000\n\n/**\n * How long the stylesheets get before the sheet is printed without them.\n *\n * An iframe's `load` event already waits for its stylesheets, which is the whole reason the styles\n * survive the copy. But \"waits for\" has no upper bound: one unreachable CDN in the page's `<head>`\n * and `load` never fires, leaving a button that does nothing forever with no error to explain it.\n * Printing an unstyled sheet is a bad outcome; printing nothing is a worse one.\n */\nconst STYLE_TIMEOUT_MS = 3_000\n\n/** Escapes a value that is about to become part of a document rather than part of the DOM. */\nfunction escapeHtml(value: string): string {\n  return value\n    .replace(/&/g, \"&amp;\")\n    .replace(/</g, \"&lt;\")\n    .replace(/>/g, \"&gt;\")\n    .replace(/\"/g, \"&quot;\")\n}\n\n/** `querySelectorAll` that also returns `root` itself when it matches, which it does not. */\nfunction collect(root: Element, selector: string): Element[] {\n  const found = Array.from(root.querySelectorAll(selector))\n  return root.matches(selector) ? [root, ...found] : found\n}\n\n/**\n * The page's own CSS, in the two forms it comes in.\n *\n * `href`s are re-linked rather than inlined: a cross-origin sheet — a font service, a CDN build —\n * throws `SecurityError` the moment its `cssRules` are read, so any implementation that serialises\n * the CSSOM silently drops exactly the stylesheets it cannot see. Linking copies them by reference\n * and sidesteps the question. Constructable stylesheets (`adoptedStyleSheets`) have no href to\n * copy and must be serialised, but they are always same-origin, so reading them is safe.\n */\nexport function collectStyleSources(doc: Document): {\n  hrefs: string[]\n  css: string[]\n} {\n  const hrefs: string[] = []\n  const css: string[] = []\n\n  for (const node of Array.from(doc.querySelectorAll(\"link[rel~='stylesheet'], style\"))) {\n    if (node.tagName === \"LINK\") {\n      const href = (node as HTMLLinkElement).href\n      // `media=\"print\"` sheets are kept: they are the ones meant for this.\n      if (href) hrefs.push(href)\n    } else {\n      css.push(node.textContent ?? \"\")\n    }\n  }\n\n  const adopted = (doc as Document & { adoptedStyleSheets?: CSSStyleSheet[] }).adoptedStyleSheets\n  for (const sheet of adopted ?? []) {\n    try {\n      css.push(Array.from(sheet.cssRules, (rule) => rule.cssText).join(\"\\n\"))\n    } catch {\n      // Unreadable sheets are skipped rather than thrown over.\n    }\n  }\n\n  return { hrefs, css }\n}\n\n/**\n * Copies live form state onto the clone, which does not have any.\n *\n * `cloneNode` copies attributes, and what the user typed is not an attribute — `input.value` is a\n * property that leaves the `value` attribute at whatever the markup said. So a filled-in form\n * serialises back to the empty form it started as: the order printed as a record of what was\n * submitted comes out blank, and it looks finished. Checkboxes, selected options and textareas all\n * fail the same way, each through a different property.\n *\n * The two trees are walked by index rather than by matching nodes, which is safe because the clone\n * is a structural copy and `querySelectorAll` returns document order on both sides.\n */\nexport function freezeFormState(source: Element, clone: Element): void {\n  const fields = \"input, textarea, select\"\n  const live = collect(source, fields)\n  const copies = collect(clone, fields)\n\n  for (let i = 0; i < live.length && i < copies.length; i++) {\n    const from = live[i]\n    const to = copies[i]\n\n    if (from instanceof HTMLInputElement && to instanceof HTMLInputElement) {\n      if (from.type === \"checkbox\" || from.type === \"radio\") {\n        if (from.checked) to.setAttribute(\"checked\", \"\")\n        else to.removeAttribute(\"checked\")\n      } else {\n        to.setAttribute(\"value\", from.value)\n      }\n    } else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {\n      to.textContent = from.value\n    } else if (from instanceof HTMLSelectElement && to instanceof HTMLSelectElement) {\n      for (let o = 0; o < from.options.length && o < to.options.length; o++) {\n        if (from.options[o].selected) to.options[o].setAttribute(\"selected\", \"\")\n        else to.options[o].removeAttribute(\"selected\")\n      }\n    }\n  }\n}\n\n/**\n * Replaces cloned canvases with a picture of what they were showing.\n *\n * A canvas is an element plus a bitmap, and cloning copies only the element — the copy is a\n * correctly sized blank rectangle. Charts, sparklines and a signature captured on a contract all\n * print as white space unless the pixels are carried over by hand.\n *\n * A canvas that has been drawn with a cross-origin image is tainted and `toDataURL` throws on it.\n * That is left as the blank rectangle it already was: one missing picture is not a reason for the\n * whole sheet to fail.\n */\nexport function freezeCanvases(source: Element, clone: Element): void {\n  const live = collect(source, \"canvas\")\n  const copies = collect(clone, \"canvas\")\n\n  for (let i = 0; i < live.length && i < copies.length; i++) {\n    const from = live[i]\n    const to = copies[i]\n    if (!(from instanceof HTMLCanvasElement)) continue\n\n    try {\n      const image = to.ownerDocument.createElement(\"img\")\n      image.src = from.toDataURL()\n      image.width = from.width\n      image.height = from.height\n      image.style.cssText = to.getAttribute(\"style\") ?? \"\"\n      image.className = to.className\n      image.alt = to.getAttribute(\"aria-label\") ?? \"\"\n      to.replaceWith(image)\n    } catch {\n      // Tainted canvas: leave the blank element rather than losing the sheet.\n    }\n  }\n}\n\n/** The rules that make a screen region behave on paper. See {@link buildPrintableDocument}. */\nconst BASE_PRINT_CSS = `\n@page { margin: 12mm; }\n:root { color-scheme: light; }\nhtml, body { margin: 0; padding: 0; background: #fff; }\n/* Browsers drop background colours and images when printing, so anything colour-coded on screen —\n   a status pill, a highlighted row, a chart's fills — comes out white on white. */\n*, *::before, *::after {\n  -webkit-print-color-adjust: exact;\n  print-color-adjust: exact;\n}\n/* A region that scrolls on screen must not scroll on paper: a scroll box keeps its height, and\n   everything below the fold is simply absent from a sheet that looks complete. */\n[${PRINT_MARKER}] * {\n  max-height: none !important;\n  max-width: none !important;\n  overflow: visible !important;\n}\n`\n\n/**\n * Builds the standalone document that gets printed.\n *\n * The `<base>` is load-bearing. A `srcdoc` frame has no URL of its own, so every relative `src` and\n * `href` in the copied markup resolves against nothing and the logo, the avatars and the product\n * shots all fail to load — on a sheet that otherwise looks right.\n *\n * The title matters more than it looks: it is what the browser puts in the \"Save as PDF\" filename\n * box, so `invoice-1041` beats whatever the page happened to be called.\n */\nexport function buildPrintableDocument({\n  title,\n  bodyHtml,\n  baseHref,\n  hrefs = [],\n  css = [],\n  pageStyle = \"\",\n  lang = \"en\",\n  dir,\n}: {\n  title: string\n  bodyHtml: string\n  baseHref: string\n  hrefs?: string[]\n  css?: string[]\n  pageStyle?: string\n  lang?: string\n  dir?: string\n}): string {\n  const links = hrefs\n    .map((href) => `<link rel=\"stylesheet\" href=\"${escapeHtml(href)}\">`)\n    .join(\"\\n\")\n  const inline = css.map((text) => `<style>${text}</style>`).join(\"\\n\")\n\n  return `<!doctype html>\n<html lang=\"${escapeHtml(lang)}\"${dir ? ` dir=\"${escapeHtml(dir)}\"` : \"\"}>\n<head>\n<meta charset=\"utf-8\">\n<base href=\"${escapeHtml(baseHref)}\">\n<title>${escapeHtml(title)}</title>\n${links}\n${inline}\n<style>${BASE_PRINT_CSS}</style>\n${pageStyle ? `<style>${pageStyle}</style>` : \"\"}\n</head>\n<body ${PRINT_MARKER}>\n${bodyHtml}\n</body>\n</html>`\n}\n\n/**\n * Prints `html` as a document of its own, and resolves once the browser is done with it.\n *\n * The frame recipe is the part that has to be exactly right, and each line below is a browser that\n * behaves differently:\n *\n * - The frame is 0x0 and transparent, never `display: none` — a frame that is not being displayed\n *   has nothing to print, and browsers say so by printing a blank page.\n * - `srcdoc` is assigned before the frame is inserted, so the only `load` event is the sheet's.\n *   Inserting first fires one for the initial `about:blank`; the marker check refuses that\n *   document even if the order is ever changed back.\n * - Printing happens on `load` rather than immediately, because that event is what waits for the\n *   copied stylesheets to arrive. {@link STYLE_TIMEOUT_MS} is the bound on that wait.\n * - Cleanup waits for `afterprint`. `print()` blocks until the dialog closes in Chrome and Firefox\n *   but returns immediately in Safari, where tearing down on the next line pulls the document out\n *   from under a dialog that is still open.\n */\nexport function printRegion(html: string): Promise<PrintOutcome> {\n  if (typeof window === \"undefined\" || typeof window.print !== \"function\") {\n    return Promise.resolve(\"unavailable\")\n  }\n\n  return new Promise((resolve) => {\n    const frame = document.createElement(\"iframe\")\n    frame.setAttribute(\"aria-hidden\", \"true\")\n    frame.setAttribute(\"tabindex\", \"-1\")\n    frame.setAttribute(\"title\", \"Print preview\")\n    frame.style.cssText =\n      \"position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none\"\n\n    let printed = false\n    let settled = false\n    let styleTimer = 0\n    let cleanupTimer = 0\n\n    const finish = (outcome: PrintOutcome) => {\n      if (settled) return\n      settled = true\n      window.clearTimeout(styleTimer)\n      window.clearTimeout(cleanupTimer)\n      frame.remove()\n      resolve(outcome)\n    }\n\n    const send = () => {\n      if (printed || settled) return\n      const frameWindow = frame.contentWindow\n      if (!frameWindow?.document.querySelector(`[${PRINT_MARKER}]`)) return\n      printed = true\n      window.clearTimeout(styleTimer)\n      cleanupTimer = window.setTimeout(() => finish(\"done\"), CLEANUP_MS)\n      frameWindow.addEventListener(\"afterprint\", () => finish(\"done\"))\n      try {\n        frameWindow.focus()\n        frameWindow.print()\n      } catch {\n        finish(\"failed\")\n      }\n    }\n\n    frame.srcdoc = html\n    frame.onload = send\n    // The fallback has to settle the promise even when it cannot print. `send` refuses a document\n    // without the marker, and a refusal that scheduled nothing after it would leave the caller\n    // awaiting a promise that never resolves — and the button disabled for good.\n    styleTimer = window.setTimeout(() => {\n      if (printed || settled) return\n      if (frame.contentWindow?.document.querySelector(`[${PRINT_MARKER}]`)) send()\n      else finish(\"failed\")\n    }, STYLE_TIMEOUT_MS)\n    document.body.appendChild(frame)\n  })\n}\n\nexport interface PrintButtonProps\n  extends Omit<React.ComponentPropsWithoutRef<\"button\">, \"children\"> {\n  /** The region to print. Everything inside it goes on the sheet; nothing else does. */\n  target: React.RefObject<HTMLElement | null>\n  /** Becomes the sheet's `<title>`, and the default filename under \"Save as PDF\". */\n  documentTitle?: string\n  /** Label shown while the button is at rest. */\n  children?: React.ReactNode\n  /** Extra CSS for the printed document, appended after the page's own. */\n  pageStyle?: string\n  /** Called before the region is serialised — the place to expand rows or reveal hidden detail. */\n  onBeforePrint?: () => void\n  /** Called once the dialog closes. `done` does not distinguish printing from cancelling. */\n  onPrintEnd?: (outcome: PrintOutcome) => void\n}\n\n/**\n * Prints one region of the page instead of the page.\n *\n * `window.print()` prints the document, which is almost never what the button next to an invoice\n * is for: the nav, the sidebar and the cookie banner come with it. The alternative usually reached\n * for is a print stylesheet that hides everything else, which pushes a global `@media print` rule\n * into the app and breaks the next time the layout changes.\n */\nexport function PrintButton({\n  target,\n  documentTitle,\n  children = \"Print\",\n  pageStyle,\n  onBeforePrint,\n  onPrintEnd,\n  onClick,\n  className,\n  disabled,\n  ...props\n}: PrintButtonProps) {\n  const [status, setStatus] = React.useState<\"idle\" | \"working\" | PrintOutcome>(\"idle\")\n  const alive = React.useRef(true)\n\n  React.useEffect(() => {\n    alive.current = true\n    return () => {\n      alive.current = false\n    }\n  }, [])\n\n  React.useEffect(() => {\n    if (status !== \"unavailable\" && status !== \"failed\") return\n    const id = window.setTimeout(() => setStatus(\"idle\"), 2500)\n    return () => window.clearTimeout(id)\n  }, [status])\n\n  async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {\n    onClick?.(event)\n    if (event.defaultPrevented || status === \"working\") return\n\n    const region = target.current\n    if (!region) {\n      setStatus(\"failed\")\n      onPrintEnd?.(\"failed\")\n      return\n    }\n\n    setStatus(\"working\")\n    onBeforePrint?.()\n\n    let html: string\n    try {\n      const clone = region.cloneNode(true) as HTMLElement\n      freezeFormState(region, clone)\n      freezeCanvases(region, clone)\n\n      const { hrefs, css } = collectStyleSources(document)\n      html = buildPrintableDocument({\n        title: documentTitle ?? document.title,\n        bodyHtml: clone.outerHTML,\n        baseHref: document.baseURI,\n        hrefs,\n        css,\n        pageStyle,\n        lang: document.documentElement.lang || \"en\",\n        dir: document.documentElement.dir || undefined,\n      })\n    } catch {\n      if (alive.current) setStatus(\"failed\")\n      onPrintEnd?.(\"failed\")\n      return\n    }\n\n    const outcome = await printRegion(html)\n    // The dialog can sit open for minutes, which is long enough to navigate away from the page\n    // that opened it.\n    if (alive.current) setStatus(outcome === \"done\" ? \"idle\" : outcome)\n    onPrintEnd?.(outcome)\n  }\n\n  const message =\n    status === \"working\"\n      ? \"Preparing to print\"\n      : status === \"unavailable\"\n        ? \"Printing is not available in this browser\"\n        : status === \"failed\"\n          ? \"Could not prepare the sheet\"\n          : null\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      disabled={disabled || status === \"working\"}\n      className={cn(\n        \"inline-flex h-9 items-center justify-center gap-2 rounded-md border border-input bg-transparent px-4 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      {/* The icon never depends on feature detection, so the server and the first client render\n          agree and nothing has to hydrate twice. */}\n      <Printer className=\"h-4 w-4\" aria-hidden=\"true\" />\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </span>\n      <span aria-hidden={message && status !== \"working\" ? true : undefined}>\n        {status === \"unavailable\" || status === \"failed\" ? message : children}\n      </span>\n    </button>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 103 components: https://pulld.pages.dev/?utm_source=cli"
}
