Designs & templates

433 block designs, 17 whole-document templates, and how to ship your own.

@storvexa/pdf-builder Chapter 8 of 14
Documentation

Design vs template

Two words that sound alike and mean different things.

Block designDocument template
ScopeOne blockThe whole document
Chosen inThe Designs tab of the inspectorThe template picker in the toolbar
ChangesStyle onlyStructure, content, style, page settings
Stored asblock.layoutIdA complete document model

Block designs

Select a block and the Designs tab shows live thumbnails of every variant that block offers. The counts vary by how much there is to say: a flagship data block like lineItems has 12, each text block has 14, and pageBreak has 4 because there are only four meaningful ways to draw one.

Designs vary structure, not just size. Hierarchy, borders, cards, panels, rules, bands, badges, density, image placement, column structure, spacing. Ten variants that differ only in font size would be one design and nine disappointments — so you will not find that here, and you should not ship it either.
The design list is one column, and it is not filtered. BlockLayout.category exists in the type and the five values are defined, but nothing reads it yet — every design is listed, in declaration order, one per row with a live preview of the real block. Set category if you like; it is recorded and will group designs when filtering ships, and changes nothing today.

Adding your own design to a built-in block

addLayout appends one design to a block that already exists. Your brand's variant appears in the same Designs tab, alongside the built-ins, with no other changes.

import { createBuiltinRegistry } from '@storvexa/pdf-builder' const registry = createBuiltinRegistry() .addLayout('documentTitle', { id: 'acme-band', nameKey: 'Acme band', // an unresolved key renders as itself category: 'bold', style: { background: '#0054a6', color: '#ffffff', padding: { top: 20, right: 24, bottom: 20, left: 24 }, align: 'start', }, }) .addLayout('lineItems', { id: 'acme-table', nameKey: 'Acme table', category: 'professional', style: { headerBackground: '#0054a6', headerColor: '#fff', rowBorder: '#e9ecef' }, }) <PdfBuilder registry={registry} />
registry.addLayout('documentTitle', { id: 'acme-band', nameKey: 'Acme band', category: 'bold', style: { background: '#0054a6', color: '#ffffff', padding: { top: 20, bottom: 20 }, }, })
Designs tab · Document title
INVOICE
Plain
INVOICE
Acme band
On the page
INVOICE
INV-0042 · 12 March

Your design sits beside the built-ins in the same tab, filtered under the category you gave it, and applying it writes layoutId: 'acme-band' and nothing else.

Starting from a built-in design

Read the existing design, keep what works, change the rest. Cheaper and safer than guessing which style keys a block understands.

const registry = createBuiltinRegistry() // What does the built-in actually set? const boxed = registry.get('signatureArea').layouts.find((l) => l.id === 'boxed') console.log(boxed.style) registry.addLayout('signatureArea', { ...boxed, id: 'acme-boxed', // a NEW id — reusing one shadows the original nameKey: 'Acme boxed', style: { ...boxed.style, borderColor: '#0054a6' }, })
Discover the style keys instead of guessing them. registry.get(type).layouts and .defaultStyle tell you exactly which properties a block responds to. A style key the block does not read is silently ignored — which looks identical to a design that "does not work".

Replacing the whole design set

When the built-in designs are wrong for your product rather than merely incomplete:

registry.extend('documentTitle', { layouts: [ { id: 'acme-plain', nameKey: 'Plain', style: {} }, { id: 'acme-band', nameKey: 'Band', style: { background: '#0054a6', color: '#fff' } }, ], })
Removing a design that documents already use. A block whose layoutId no longer exists falls back to the block's default style — it does not break, but it does change appearance. Keep the old id in the list, or accept that existing documents will shift.

Designs and manual edits

Applying a design writes layoutId and nothing else. It never touches content, so the words survive — and because manual overrides sit above the design in style resolution, a hand-set colour survives too.

If the block has manual overrides, the builder asks. Keep them, and the new design shows through everywhere the user did not intervene. Reset them, and the design applies cleanly. What it never does is silently discard work someone did by hand.
The flip side, and the cause of most "the design isn't working" reports: if a user previously set a property by hand, a new design cannot change that property. It is not broken — the override is winning, exactly as designed. "Reset style" clears it.

Document templates

17 built-in whole-document templates, covering the shapes real businesses print: minimal business, modern professional, corporate, elegant, bold accent, compact commercial, service invoice, retail/product, quotation, report, letter, certificate, international, dark header, image-led product and clean monochrome.

Applying a template REPLACES the whole document, with no confirmation. Rows, page settings, tokens, header, footer, cover and the document name — all of it, even when the user has authored content. It is one undo step, so Ctrl+Z is the only way back. If your users can lose work that way, put your own confirmation in front of the toolbar button.
The picker shows names and descriptions, not previews. There is no thumbnail, no category filter and no search, whatever TemplateCategory suggests.
A template is a document, never a screenshot. Applying one gives real blocks in real rows. Every part of it is selectable, editable, draggable and deletable the moment it lands — because it is just a document like any other.
import { createBuiltinTemplates } from '@storvexa/pdf-builder' createBuiltinTemplates().forEach((t) => console.log(t.id, t.name))

Turn them off entirely if you only want your own:

<PdfBuilder builtinTemplates={false} templates={myTemplates} />

Your own templates

The shape

interface DocumentTemplate { id: string nameKey: LabelKey // a label key — but an unresolved key renders as itself, descriptionKey?: LabelKey // so plain readable text is fine for your own templates document: PdfDocument // a complete document category?: TemplateCategory // recorded; the picker does not filter by it yet tags?: string[] thumbnailSvg?: string // declared, but the preview is always a live render }
It is nameKey, not name. Everything user-facing in the package is a label key. A key that no locale pack resolves falls back to rendering itself, so nameKey: 'Acme invoice' displays exactly that — which is why you can ignore the translation machinery for your own templates and still be consistent with it when you need to.

Authoring one

Build it in the builder, take the JSON from onChange, and paste it in. There is no separate template format to learn — a template is a document.

<PdfBuilder templates={[ { id: 'acme-invoice', nameKey: 'Acme invoice', descriptionKey: 'Our standard invoice, brand colours applied', category: 'corporate', document: savedDocumentJson, // exactly what onChange gave you }, ]} />

Extending a built-in template

Rather than starting from an empty page, take one of the 17 built-ins and change what you need. This is usually the fastest way to a brand-consistent set.

import { createBuiltinTemplates } from '@storvexa/pdf-builder' const builtins = createBuiltinTemplates() const modern = builtins.find((t) => t.id === 'modern-professional') const acmeModern = { ...modern, id: 'acme-modern', // a NEW id — never reuse the built-in's nameKey: 'Acme — modern', document: { ...modern.document, tokens: { ...modern.document.tokens, // keep everything you are not changing primaryColor: '#0054a6', fontFamily: "'Inter', sans-serif", }, page: { ...modern.document.page, size: 'A4', margin: { top: 18, right: 16, bottom: 18, left: 16 } }, }, } <PdfBuilder templates={[...builtins, acmeModern]} />
Spread, do not mutate. createBuiltinTemplates() hands you objects; editing them in place changes what every other builder instance on the page sees. Spread each level you touch, as above.

Replacing a built-in with your version

Same id, your document — the picker shows yours and never the original.

const templates = createBuiltinTemplates().map((t) => t.id === 'minimal-business' ? { ...t, document: myMinimalDocument } : t, ) <PdfBuilder builtinTemplates={false} templates={templates} />
builtinTemplates={false} plus your own list is how you take full control. Leave it true (the default) and your templates are added to the built-ins. Set it false and only your list is offered — which is what you want when the built-ins would confuse your users or breach your brand guidelines.

Loading them from your server

<PdfBuilder onLoadTemplates={async () => { const rows = await (await fetch('/api/templates')).json() return rows.map((r) => ({ id: r.id, nameKey: r.name, document: JSON.parse(r.json) })) }} />
Author templates with placeholder tokens, not real data. A template is meant to be reused. If it contains "Acme Ltd, invoice INV-0042", every document made from it starts with someone else's facts in it. Use {{ customer.name }} and friends — see Images & data.

Letting users save templates

Not built yet. Do not design around it. onSaveTemplate, onLoadTemplates and onDeleteTemplate exist in the props type and nothing in the package calls them. There is no “Save as template” button, dialog or menu item anywhere in the interface, so passing these callbacks has no effect at all.

Until it ships, do it yourself — you already have everything you need:

// Your own button, outside the builder. const saveAsTemplate = async () => { const doc = builderRef.current.getDocument() // the ref handle from Quick start await fetch('/api/templates', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: prompt('Template name'), document: doc }), }) } // Then feed them back in as ordinary templates. <PdfBuilder templates={await loadMine()} />
The templates prop IS implemented — that is how your saved documents get back into the picker. Only the save/load/delete callbacks are missing.