Custom blocks

Build a block of your own: the definition, the schema-driven form, the renderer, its designs, and registering it.

@storvexa/pdf-builder Chapter 6 of 14
Documentation

The whole thing, at once

A complete, working custom block. Read it once, then read the sections underneath for what each part is doing.

import { BlockRegistry, createBuiltinRegistry } from '@storvexa/pdf-builder' const deliveryNote = { type: 'deliveryNote', labelKey: 'block.deliveryNote.name', category: 'business', kind: 'static', defaultContent: { heading: 'Delivery instructions', body: '', showHeading: true }, defaultStyle: { align: 'start', accent: null }, contentSchema: [ { key: 'showHeading', type: 'boolean', labelKey: 'block.deliveryNote.showHeading' }, { key: 'heading', type: 'text', labelKey: 'block.deliveryNote.heading', showIf: (v) => v.showHeading === true }, { key: 'body', type: 'text', multiline: true, rows: 4, labelKey: 'block.deliveryNote.body' }, ], styleSchema: [ { key: 'align', type: 'align', labelKey: 'field.align' }, { key: 'accent', type: 'color', labelKey: 'field.accent' }, ], layouts: [ { id: 'plain', nameKey: 'block.deliveryNote.design.plain', style: {} }, { id: 'panel', nameKey: 'block.deliveryNote.design.panel', category: 'professional', style: { padding: { top: 16, bottom: 16 } } }, ], Component({ content, style, ctx }) { return ( <div style={{ textAlign: style.align, color: ctx.tokens.textColor }}> {content.showHeading && ( <h4 style={{ color: style.accent ?? ctx.tokens.primaryColor }}>{content.heading}</h4> )} <p style={{ whiteSpace: 'pre-wrap' }}>{content.body}</p> </div> ) }, } const registry = createBuiltinRegistry().register(deliveryNote) <PdfBuilder registry={registry} labels={{ 'block.deliveryNote.name': 'Delivery note' }} />

What that code produces

Those forty lines give you three things at once, with no further wiring. Here is each of them, next to the part of the definition responsible for it.

1 · An entry in the palette

type: 'deliveryNote', labelKey: 'block.deliveryNote.name', category: 'business', icon: <TruckIcon />,
Blocks panel
Business
Document details
Key/value details
Delivery note yours
Payment details

category decides which group it lands in; labelKey is what it is called there. It is draggable immediately.

2 · A block on the page

defaultContent: { heading: 'Delivery instructions', body: '', showHeading: true, }, Component({ content, style, ctx }) { return ( <div style={{ textAlign: style.align }}> {content.showHeading && <h4>{content.heading}</h4>} <p>{content.body}</p> </div> ) },
Canvas
INVOICE
INV-0042 · 12 March
Delivery instructions
Leave with reception if unattended.
It drops in already looking designed. Because defaultContent gives it a heading rather than leaving it blank. A block that lands empty makes the user do design work before they can judge whether they want it.

3 · An inspector, built from your schema

contentSchema: [ { key: 'showHeading', type: 'boolean', labelKey: 'block.deliveryNote.showHeading' }, { key: 'heading', type: 'text', labelKey: 'block.deliveryNote.heading', showIf: (v) => v.showHeading === true }, { key: 'body', type: 'text', multiline: true, rows: 4, labelKey: 'block.deliveryNote.body' }, ],
Inspector
Content Style Designs
Show heading
Heading
Delivery instructions
Body
Leave with reception…
You wrote no inputs and no change handlers. The switch, the text box, the textarea, their labels, their reset affordances and their keyboard behaviour all came from those three schema entries. Turn "Show heading" off and the Heading field disappears — that is the showIf line doing its work.

The definition

FieldRequiredWhat it does
typeyesUnique id. Stored in every block instance — treat it as permanent once documents exist.
labelKeyyesIts name in the palette, as a label key. Never a literal string.
categoryyesWhich palette group: basic, brand, text, parties, business, data, products, media, proof, layout, custom.
kindyes'static' or 'databound'.
defaultContentyesWhat a freshly dropped block says.
defaultStyleyesLayer 2 of style resolution.
ComponentyesThe canvas renderer.
descriptionKeyHelper text in the palette.
iconA React node.
contentSchema / styleSchemaDeclarative forms. Strongly preferred over custom editors.
ContentEditor / StyleEditorEscape hatch: a bespoke React editor. Needs a documented reason.
tabsWhich inspector tabs appear: 'content', 'style', 'template'.
layoutsThe designs offered in the Designs tab.
maxInstancesCap how many can exist in one document.
requiresFeatureGate it behind a licence feature. See Licensing.
ai{ aliases, capabilities } — helps an AI service pick your block correctly.
tabs uses 'template' for the Designs tab. The visible label is "Designs"; the id stayed template for backward compatibility.

Schema-driven forms

You describe the fields; the inspector builds the form. You do not write inputs, wire up change handlers, or think about reset affordances, disabled states or accessibility — all of that comes from the schema.

Reach for a schema first, every time. A bespoke editor is an escape hatch, not a shortcut. Schema fields get host field-control overrides, label translation, reset affordances and keyboard behaviour for free; a custom editor gets none of it unless you build it.

Every variant shares these keys:

{ key: 'heading', // which content/style key it writes type: 'text', // which control to render labelKey: 'block.x.heading', // its name — a KEY, never a literal descriptionKey: 'block.x.hint', // optional helper text showIf: (values) => values.on, // optional conditional visibility }

Every field type

TypeExtra keysRenders
textplaceholderKey, multiline, rows, labelled, activeKeyA text input, or a textarea with multiline.
numbermin, max, step, unitA numeric input with its unit shown.
booleanA switch. Never a checkbox — there is exactly one boolean control in the package.
selectoptions: [{ value, labelKey }]A dropdown.
fontA typeface picker fed by your fonts prop. Empty means "follow the document".
colorA colour picker.
spacingA four-sided spacing control.
alignStart / center / end.
imageUpload or pick from the library; stores a path.
listitemFields, addLabelKeyA repeatable group — table rows, gallery items.
columnsColumn visibility and order, for table-like blocks.
groupfields, collapsed, flatA section. flat: true means the children write top-level keys rather than a nested object.
customComponentYour own control, for the rare case nothing above fits.

Labelled fields

labelled: true changes what a text field means. Instead of editing a value, the user edits the label in front of a value that comes from a record. The control writes <key>Label and <key>LabelShown.

{ key: 'number', type: 'text', labelKey: 'field.number', labelled: true, // user edits the LABEL, not the value activeKey: 'showNumber' } // …and one switch turns the whole row on and off
Only mark a field labelled if your renderer actually draws a label. Otherwise you offer a control that changes nothing on the page — exactly the false promise this feature exists to remove.

The renderer

Component({ block, content, style, ctx }) { … }
blockThe raw block — id, type, layoutId. Rarely needed.
contentYour content, already interpolated: {{ tokens }} have been replaced.
styleFully resolved style. All four layers already merged.
ctxThe render context — see below.
Never resolve style yourself. style arrives finished. Reading block.overrides in a renderer means re-implementing precedence, and it will drift.

The render context

MemberUse it for
ctx.tokensDocument-wide colours, fonts, spacing — your fallbacks.
ctx.formatMoney(n)Every visible amount. Never hardcode a currency symbol.
ctx.label(key, fallback?)Every visible string your renderer owns.
ctx.resolveImageUrl(path)Turn a stored path into something <img> can use.
ctx.onUploadImage(file)Upload from inside a block, returning a path.
ctx.interpolate(text)Resolve tokens in text the renderer owns — preview rows, for instance. Content is already done for you.
ctx.direction'ltr' or 'rtl' — mirror structural layout.
ctx.documentTypeFor text you derive rather than text a user typed.
ctx.readOnlyHide editing affordances.
ctx.printingTrue while rendering for the PDF.
ctx.printing — keep the size, drop the guidance. Use it to remove editor-only hints ("Add logo", a dashed empty outline) while keeping the element's dimensions. Removing the element instead changes the layout between the canvas and the PDF, and an unfilled slot should simply print blank.

Giving it designs

layouts: [ { id: 'boxed', nameKey: 'block.deliveryNote.design.boxed', category: 'professional', // professional | minimal | bold | editorial | compact style: { border: '1px solid', padding: { top: 16, bottom: 16 } }, }, ]
A design writes style. Only style. Never content. This is a hard rule, not a convention — it is what lets a user try every design without losing a word they typed.
Do not create variety by nudging a font size. Ten designs that differ only in type scale are one design and nine disappointments. Vary real structure: borders, cards, bands, rules, badges, density, image placement, column structure.

Aim for 8–12 designs on a standard content block, 10–20 on a flagship data block, and 4–8 on a utility block where they are meaningful at all.

Registering it

import { createBuiltinRegistry } from '@storvexa/pdf-builder' // Start from the built-ins and add yours. const registry = createBuiltinRegistry() .register(deliveryNote) .register(warrantyPanel) <PdfBuilder registry={registry} />
Build the registry once, outside render. A new registry object on every render remounts the palette. Use a module constant or useMemo with a stable dependency list.

Your block now appears in the palette, is draggable, gets an inspector built from your schemas, participates in undo/redo, and is offered to your AI service as a valid block — without any further wiring.

Rules that will bite you

No literal user-facing strings. Block names, field labels, design names, placeholders, errors — all label keys. A literal string cannot be translated or overridden by the host, and it will be the reason someone cannot ship your block in their language.
No hardcoded currency symbols. Not $, not , not , not in the renderer and not in a design thumbnail. Use ctx.formatMoney().
No invented facts in defaultContent. Never a real-looking name, address or invoice number. Use a {{ token }} placeholder, or authored design copy the user is meant to rewrite. A block that drops in showing "Acme Ltd, 42 Main Street" produces documents that ship with fake data in them.
No storing URLs or base64 in the document. Images are opaque paths. Always.
Do make it drop in looking finished. A block that lands blank makes the user do design work before they can judge it. Give it sensible defaults and a default design.