pdf-export · git:20260523.a0d8894 · 2026-05-23 · sha256 5c9fd079afb2c587
pdf-export git:20260523.a0d8894A
Immutable. This exact content is served forever at /api/v1/blob/5c9fd079afb2c587.
---
name: pdf-export
description: Add client-side PDF export to a web app using jspdf + html2canvas-pro. Use when the user asks to download, save, or export the current page, a report, a slide deck, a chart, or any DOM element as a PDF. Covers single-element snapshots and multi-page documents with TOC bookmarks. No server, no Puppeteer at runtime - all browser-side.
---
# Add client-side PDF export
Wire a "Download PDF" feature into a React/TypeScript web app. The output is generated entirely in the browser - the user clicks a button, the SPA rasterizes the relevant DOM nodes, and `jspdf` assembles the PDF and triggers a download.
## When to trigger this skill
- "Add a download PDF button..."
- "Let users export this report / slide / dashboard / chart as a PDF"
- "Save the current page as PDF"
- "Print this to a file"
- "We need PDF export"
- Any time a developer asks for browser-native PDF generation (no server, no Puppeteer)
Do **not** use this skill if the user wants:
- Server-rendered PDFs (use Puppeteer / Playwright on a server)
- Vector PDFs from data structures (use `pdfkit` or `pdfmake`)
- "Print this page" with `window.print()` (that's a one-liner; just suggest it)
## Step 1: Clarify the scope
Before writing code, ask the user 2 short questions if any of these are unclear:
1. **Single element or multi-page?**
- One DOM element (a report, a card, a chart) -> Pattern A
- A sequence of slides / pages / sections -> Pattern B
2. **Page size?** Default to letter or A4 portrait for reports; 16:9 landscape (1920x1080) for slide decks. Confirm if unsure.
For multi-page docs, also ask: **do they want a clickable TOC** (outline bookmarks)? Default yes for >3 pages.
## Step 2: Detect framework and package manager
```bash
# In project root:
cat package.json | head -20 # look for react/next/vite/vue
test -f bun.lock && echo bun || test -f pnpm-lock.yaml && echo pnpm || test -f yarn.lock && echo yarn || echo npm
grep -E '"tailwindcss":' package.json # check Tailwind major version
```
**Critical**: if Tailwind 4 is in use, you MUST use `html2canvas-pro` (not vanilla `html2canvas`). Tailwind 4 emits `oklch()` colors that vanilla html2canvas cannot parse and the export will throw at runtime. Tailwind 3 works with either, but prefer `-pro` anyway - it's a maintained fork.
## Step 3: Install
Pick the matching command:
```bash
bun add jspdf html2canvas-pro
# OR
pnpm add jspdf html2canvas-pro
# OR
npm install jspdf html2canvas-pro
# OR
yarn add jspdf html2canvas-pro
```
No type packages needed - both libraries ship their own.
## Pattern A: Single-element snapshot
For a single report / page / dashboard. Target by `ref` or a known DOM selector.
`src/lib/exportToPdf.ts`:
```ts
import html2canvas from "html2canvas-pro";
import jsPDF from "jspdf";
type ExportOptions = {
filename?: string;
/** Page size as [width, height] in PDF points (1 pt = 1/72 inch). */
format?: [number, number] | "a4" | "letter";
orientation?: "portrait" | "landscape";
/** Background to fill in transparent areas. */
backgroundColor?: string;
};
/**
* Snapshot one DOM node into a single-page PDF. Use for reports / dashboards
* / cards. For multi-page exports, see exportDeckToPdf.
*/
export async function exportElementToPdf(
node: HTMLElement,
options: ExportOptions = {},
): Promise<void> {
const { filename = "export.pdf", format = "a4", orientation = "portrait", backgroundColor = "#ffffff" } = options;
// Wait one extra frame so any in-flight layout (lazy fonts, recently mounted
// children) settles before the canvas snapshot freezes everything.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
const canvas = await html2canvas(node, {
backgroundColor,
scale: 2, // 2x for retina-quality output
useCORS: true,
logging: false,
});
const pdf = new jsPDF({ orientation, unit: "pt", format, compress: true });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
// Fit the captured image into the page while preserving aspect ratio.
const imgAspect = canvas.width / canvas.height;
const pageAspect = pageWidth / pageHeight;
const drawWidth = imgAspect > pageAspect ? pageWidth : pageHeight * imgAspect;
const drawHeight = imgAspect > pageAspect ? pageWidth / imgAspect : pageHeight;
const dx = (pageWidth - drawWidth) / 2;
const dy = (pageHeight - drawHeight) / 2;
pdf.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", dx, dy, drawWidth, drawHeight, undefined, "FAST");
pdf.save(filename);
}
```
Wire to a button:
```tsx
import { useRef } from "react";
import { exportElementToPdf } from "./lib/exportToPdf";
export function Report() {
const ref = useRef<HTMLDivElement>(null);
return (
<>
<button onClick={() => ref.current && void exportElementToPdf(ref.current, { filename: "report.pdf" })}>
Download PDF
</button>
<div ref={ref}>{/* report content */}</div>
</>
);
}
```
## Pattern B: Multi-page document with TOC bookmarks
For slide decks, multi-section reports, anything with >1 page. Each page is rendered off-screen at fixed dimensions so the output is pixel-deterministic regardless of viewport.
`src/lib/exportDeckToPdf.ts`:
```ts
import html2canvas from "html2canvas-pro";
import jsPDF from "jspdf";
type PageInput = {
/** Returns the DOM node to rasterize for this page. */
render: () => Promise<HTMLElement>;
/** Shown as a clickable bookmark in the PDF viewer's outline pane. */
title: string;
};
type ExportOptions = {
filename?: string;
width?: number;
height?: number;
backgroundColor?: string;
onProgress?: (current: number, total: number) => void;
};
export async function exportDeckToPdf(
pages: PageInput[],
options: ExportOptions = {},
): Promise<void> {
const { filename = "deck.pdf", width = 1920, height = 1080, backgroundColor = "#0b1220", onProgress } = options;
const pdf = new jsPDF({ orientation: "landscape", unit: "px", format: [width, height], compress: true });
// jsPDF's outline API isn't typed - cast it once.
const outline = (pdf as unknown as {
outline: { add: (parent: unknown, title: string, options: { pageNumber: number }) => void };
}).outline;
for (let i = 0; i < pages.length; i++) {
onProgress?.(i, pages.length);
const page = pages[i]!;
const node = await page.render();
const canvas = await html2canvas(node, {
backgroundColor,
width, height, windowWidth: width, windowHeight: height,
scale: 1,
useCORS: true,
logging: false,
});
const data = canvas.toDataURL("image/jpeg", 0.92);
if (i > 0) pdf.addPage([width, height], "landscape");
pdf.addImage(data, "JPEG", 0, 0, width, height, undefined, "FAST");
outline?.add(null, `${i + 1}. ${page.title}`, { pageNumber: i + 1 });
}
onProgress?.(pages.length, pages.length);
pdf.save(filename);
}
```
When pages are React components (not pre-mounted DOM), use this off-screen render hook:
```tsx
import { useCallback, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import type { ComponentType } from "react";
import { exportDeckToPdf } from "./exportDeckToPdf";
type Page = { title: string; Component: ComponentType };
export function useDeckExport(pages: Page[], width = 1920, height = 1080) {
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null);
const exportPdf = useCallback(async () => {
if (exporting) return;
setExporting(true);
setProgress({ current: 0, total: pages.length });
// Off-screen host - sized to the canvas, positioned far off the viewport.
const host = document.createElement("div");
Object.assign(host.style, {
position: "fixed", left: "-100000px", top: "0",
width: `${width}px`, height: `${height}px`, background: "#0b1220",
});
document.body.appendChild(host);
document.body.classList.add("export-mode"); // hide chrome + freeze animations
const root: Root = createRoot(host);
const renderAt = (i: number): Promise<HTMLElement> =>
new Promise((resolve) => {
const Page = pages[i]!.Component;
root.render(<Page />);
// Two RAF ticks so layout + fonts are stable before we screenshot.
requestAnimationFrame(() => requestAnimationFrame(() => resolve(host)));
});
try {
await exportDeckToPdf(
pages.map((p, i) => ({ title: p.title, render: () => renderAt(i) })),
{ filename: "deck.pdf", width, height, onProgress: (c, t) => setProgress({ current: c, total: t }) },
);
} finally {
root.unmount();
host.remove();
document.body.classList.remove("export-mode");
setExporting(false);
setProgress(null);
}
}, [exporting, pages, width, height]);
return { exporting, progress, exportPdf };
}
```
Add CSS to freeze animations and hide interactive chrome during rasterization:
```css
/* Hide toolbars / sidebars / floating buttons so they don't appear in the PDF. */
.export-mode .pdf-hide { display: none !important; }
/* Pause SMIL animations and CSS transitions during snapshot. */
.export-mode * {
animation-play-state: paused !important;
transition: none !important;
}
/* If you also want to respect prefers-reduced-motion for the same elements: */
@media (prefers-reduced-motion: reduce) {
.pdf-hide animateMotion, .pdf-hide animate { display: none; }
}
```
Mark any deck chrome (nav buttons, side panels) with `className="pdf-hide"` so it disappears during export.
## Common gotchas (resolve before claiming done)
1. **Tailwind 4 + vanilla html2canvas = runtime error.** Use `html2canvas-pro` whenever the project has `tailwindcss@^4`. Vanilla `html2canvas` cannot parse `oklch()` colors.
2. **Cross-origin images** (S3 avatars, CDN logos) need `useCORS: true` AND the image server must send `Access-Control-Allow-Origin`. Otherwise the canvas gets tainted and `toDataURL` throws. If you can't fix the server, set `allowTaint: true` and accept that you can't read back the canvas - which kills PDF export. Better: proxy the image through your own origin.
3. **Web fonts not loaded** -> rasterized text falls back to system fonts. Await `document.fonts.ready` before the snapshot if the page uses custom fonts.
4. **SVG `<animateMotion>` mid-flight** -> the snapshot catches the dot mid-path. The `.export-mode` class above pauses CSS animations, but SMIL (`animate`, `animateMotion`) needs the CSS selector above OR setting `display: none` on the animate elements during export.
5. **Layout shifts after mount** -> the 2-frame `requestAnimationFrame` trick gives React time to commit and the browser time to lay out. If the page has async content (data fetches, lazy components), `await` that before exporting.
6. **Bundle size** -> `jspdf` + `html2canvas-pro` are ~400 KB total. Behind a dynamic `import()` if you care about initial load:
```ts
const exportPdf = async () => {
const { exportElementToPdf } = await import("./lib/exportToPdf");
await exportElementToPdf(node);
};
```
7. **iOS Safari** -> sometimes blocks `pdf.save()` if it's not in a synchronous click handler. Trigger the export from `onClick` directly, not from a setTimeout/Promise chain.
## Step 4: Verify
After wiring it up, run these checks:
1. **Dev sanity**: open the page, click the button, confirm a PDF downloads with the expected content.
2. **Visual fidelity**: open the PDF and compare against the screen. Look for missing fonts, cropped content, color shifts, or missing logos.
3. **TOC (multi-page)**: open the PDF in Preview / Acrobat; the sidebar should show clickable bookmarks for each page.
4. **CORS images**: if the page contains any external images, verify they appear in the PDF (not blank rectangles).
5. **Bundle**: run `bun run build` (or `vite build` / `next build`) and check the chunk size delta. Suggest dynamic import if it's > 200 KB.
## Don't introduce these anti-patterns
- Don't add a `print:` Tailwind CSS path - that's for `window.print()`, not for jspdf rasterization.
- Don't snapshot a child element when you actually want the full page - `html2canvas` doesn't follow `position: fixed` siblings outside the target.
- Don't wrap `pdf.save()` in `setTimeout` - it breaks iOS Safari's user-gesture requirement.
- Don't pre-mount the off-screen host inside React tree state - use a vanilla DOM node + a temp `createRoot` so it doesn't leak into devtools / React strict-mode double renders.