Blocks
The 44 built-in blocks, how a user works with them on the canvas, and how to add actions of your own.
The catalogue
44 blocks in nine categories, carrying 433 designs between them. Every one is real — there is no "coming soon" entry in the palette.
| Category | # | Blocks (designs each) |
|---|---|---|
| Brand | 3 | companyBlock (11), documentTitle (10), brandBanner (8) |
| Text | 11 | introText, salesText, closingText, thankYouText, infoText, documentTerms, salesTerms, paymentTerms, shippingTerms, legalDisclaimer, notes — 14 designs each |
| Basic | 1 | richText (14) |
| Parties | 6 | billingAddress, shippingAddress, supplierAddress, customerAddress, contactPerson, customParty — 10 each |
| Business | 4 | documentMeta (10), keyValueDetails (8), statusBadge (8), paymentDetails (8) |
| Products | 6 | lineItems (12), productCard (10), productImage (8), productGallery (7), specTable (7), offerPanel (8) |
| Media | 1 | image (10) |
| Proof | 6 | signatureArea (9), attachmentImage (6), attachmentsList (6), companyStamp (6), qrCode (6), barcode (6) |
| Layout | 6 | sectionHeading (8), divider (8), spacer (5), pageBreak (4), footer (6), coverPage (10) |
Read the catalogue at runtime rather than copying this table — it reflects your registry, including blocks you added and built-ins you removed.
Adding a block
Three ways, and they all end in the same place — a block inside a column.
- Drag from the palette onto the page. A ghost slot shows exactly where it will land.
- Drop onto a row rather than between rows, and it becomes a new column in that row — which is what the ghost promised before you released.
- Drop into the header, footer or cover band. Those take up to three columns too, exactly like the body.
Moving and reordering
Rows and columns sort optimistically: elements physically move as you drag, so releasing confirms what is already on screen rather than changing it. You can drag a column out of one row and into another.
Drags start from the block's handle, not its body — otherwise selecting text inside a block would start dragging it.
The inspector: three tabs
Select a block and the right-hand panel shows three tabs. The split is strict, and knowing it saves a lot of hunting.
| Tab | Edits | Written to |
|---|---|---|
| Content | What the block says — words, values, image paths, which rows show | block.content |
| Style | Manual appearance changes for this one block | block.overrides |
| Designs | Choosing a professional preset | block.layoutId |
template.
The visible label is "Designs", but the id stayed template for backward
compatibility — and it is the id you use in fields keys. See
Customising.
Advanced controls are collapsed by default, a modified value shows a reset affordance, and every field visibly affects the selected block. If a control appears to do nothing, that is a bug — please report it.
Labelled fields
Some fields are a single control carrying four things at once: the field's name, a switch for whether it appears at all, an input for the label's wording, and a switch for whether that label prints.
Block actions
Selecting a block shows an action bar. The built-ins:
| Duplicate | A copy directly beneath, with fresh ids and identical content and style. |
| Delete | Removes it. Undoable. |
| Move | The drag handle. |
| Reset style | Clears overrides, so the block falls back to its design and the document tokens. |
The action bar is positioned so it never covers the block's own content.
Adding your own actions
The actions prop adds entries to that bar. This is the hook for product-specific
behaviour — "fill from CRM", "insert last month's totals", "send for review".
| Field | Notes |
|---|---|
id | Unique string. Yours to choose. |
label | Shown in the action bar and read by screen readers. |
icon | Optional React node. |
run(blockId, doc) | Called on activation with the selected block's id and the current document. |
run does not mutate the document for you.
Treat doc as read-only. To change the document, go through your own state or
ref.loadDocument() — that way the change is validated and enters history as one
undoable step.
An action that actually changes the block
The package exports the same pure operations its own UI uses, so you build the next document and hand it back. This one fills a block from your CRM:
The operations you can build with
All pure: they take a document and return a new one, never mutating the original.
findBlock(doc, id) | Locate a block. |
updateBlockContent(doc, id, patch) | Change what it says. |
updateBlockOverrides(doc, id, patch) | Change its manual style. |
setBlockLayout(doc, id, layoutId) | Apply a design. |
clearBlockOverrides(doc, id) | Reset its style. |
duplicateBlock(doc, id) / removeBlock(doc, id) | Copy or delete. |
createBlockInstance(def) | A fresh block from a definition. |
appendBlockRow(doc, block)insertBlockRowAfter(doc, rowId, block) | Add a new row. |
block.type at the top of run and return early, as above, so
"Fill from CRM" on a divider does nothing rather than something wrong.
loadDocument on every keystroke of a long operation.
Each call is one history entry. Build the whole next document, then hand it over once.
Why so many text blocks?
Eleven text entries in the palette — intro, sales, closing, thank-you, terms, disclaimer, notes and the rest — share one renderer, one schema and one set of designs. They are not eleven implementations; they are one engine with eleven semantic presets.
Why bother, if they render the same?
- A user looking for "payment terms" finds it by that name, instead of adding a generic text block and remembering what it was for.
- Each preset drops in with sensible default wording rather than empty.
- An AI service — and your own code — can tell what a paragraph is, which makes "put the terms at the bottom" something a machine can act on.
The same is true of the six party blocks: one address engine, six role presets. When you write your own blocks, copy this pattern rather than duplicating a renderer.