Designing for PDF rendering
Your templates are rendered by Chromium headless (via Gotenberg) and the output is a static PDF. That changes the design constraints in non-obvious ways.
The mental model
The renderer:
- Loads your HTML + the CSS framework you picked (Tailwind by default).
- Loads the Google Fonts you've added.
- Compiles the Handlebars and injects
variables. - Snapshots the page to PDF using Chromium's print engine.
Everything that happens at print time is what ends up in the file. Anything that depends on time, interaction, or animation is either skipped or frozen.
Things to drop
Animations & transitions
/* All of this is invisible in the PDF and just costs render time. */
.btn { transition: all 200ms; }
@keyframes pulse { ... }
.spin { animation: spin 1s linear infinite; }
The hover/focus pseudo-classes don't fire either — there's no cursor.
Interactivity
No <button> JavaScript handlers, no <input> fields, no scroll-driven effects. Just text, layout, images.
Time-dependent values
const today = new Date() // freezes at render time, that's fine
const random = Math.random() // same — fine, but be aware
If you genuinely want "today's date" on the PDF, pre-compute it in variables so you control what's printed regardless of when the render happens.
Things to prefer
Vector primitives
HTML + CSS + inline SVG stay vectorial in the PDF. They look crisp at any zoom level and stay sharp on print.
<svg viewBox="0 0 100 100" class="h-24 w-24">
<circle cx="50" cy="50" r="45" fill="#0f172a" />
</svg>
That circle prints at 600dpi just as well as at 72dpi. No image to chase.
Tailwind + utility classes for layout
Tailwind shines in print — predictable spacing, clean typography, no JavaScript dependencies. The framework is the default for new templates.
Google Fonts via the dashboard
Add fonts from the editor's font picker, then use them with Tailwind's font-family classes (font-sans, custom via font-display if you've set a master). The renderer fetches and subsets them per generation.
Canvas-based charts (Chart.js, ECharts, …)
Canvas content rasterizes in the PDF. At default resolution it looks fuzzy. Two options:
- Render at high pixel density. For Chart.js, before creating any chart:
Chart.defaults.devicePixelRatio = 4
That oversamples the canvas 4× — the resulting bitmap stays sharp when embedded. - Use SVG output where the library supports it. For ECharts:
const chart = echarts.init(el, null, {renderer: 'svg'})
SVG is always crisp. Prefer it whenever the library offers it.
Page format & landscape
Each template has a format (A1 through A6) and a landscape boolean. Pick them based on content:
| Content | Pick |
|---|---|
| Invoice, receipt, single-page letter | A4 portrait |
| Two-page side-by-side comparison | A4 landscape |
| Pocket-size receipt | A6 |
| Poster, large diagram | A3 portrait/landscape |
You can change these from the editor (right-side properties panel) or via PATCH /v1/documents/{id} from code.
Fonts and weight variants
Add only the weights you actually use — every variant slows the render. A typical document needs 400 for body and 600 or 700 for titles, that's it.
In the editor: Fonts → +Add → Inter Tight → variants 400, 600. Set master: true on the font you want as the body default; others apply when you explicitly use them in CSS (e.g. via Tailwind font-display).
Page breaks
CSS break-before-page / break-after-page / break-inside-avoid work and are the canonical way to control pagination:
<section class="break-after-page">
Page 1 content
</section>
<section>
Page 2 content
</section>
<table class="break-inside-avoid">
<!-- never split this table across two pages -->
</table>
A typical invoice in 60 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Invoice {{invoice.number}}</title>
</head>
<body class="font-sans text-slate-900">
<main class="p-12">
<header class="flex items-start justify-between border-b border-slate-200 pb-6">
<div>
<p class="text-xs uppercase tracking-widest text-slate-500">Invoice</p>
<h1 class="font-display text-3xl font-semibold mt-1">
{{invoice.number}}
</h1>
</div>
<div class="text-right">
<p class="text-sm">Issued {{invoice.issuedOn}}</p>
<p class="text-sm text-slate-500">Due {{invoice.dueOn}}</p>
</div>
</header>
<section class="grid grid-cols-2 gap-8 mt-8">
<div>
<p class="text-xs uppercase tracking-wider text-slate-500">Billed to</p>
<p class="mt-1 font-semibold">{{customer.name}}</p>
<p class="text-sm text-slate-600">{{customer.address.line1}}</p>
<p class="text-sm text-slate-600">{{customer.address.city}}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-slate-500">From</p>
<p class="mt-1 font-semibold">{{seller.name}}</p>
<p class="text-sm text-slate-600">{{seller.address}}</p>
</div>
</section>
<table class="mt-10 w-full text-sm">
<thead class="border-b border-slate-200 text-left">
<tr>
<th class="py-2">Item</th>
<th class="py-2 text-right">Qty</th>
<th class="py-2 text-right">Amount</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr class="border-b border-slate-100">
<td class="py-2">{{label}}</td>
<td class="py-2 text-right">{{qty}}</td>
<td class="py-2 text-right">{{amount}} €</td>
</tr>
{{/each}}
</tbody>
<tfoot>
<tr>
<td class="pt-4 font-semibold" colspan="2">Total</td>
<td class="pt-4 font-semibold text-right">{{invoice.total}} €</td>
</tr>
</tfoot>
</table>
{{#if paid}}
<p class="mt-10 text-emerald-700 font-semibold">PAID</p>
{{/if}}
</main>
</body>
</html>
Next steps
- Modeling your variables → — what to make a variable vs. inline.
- Working with the AI assistant → — ask Claude to draft your next template.