Custom blocks
Build a block of your own: the definition, the schema-driven form, the renderer, its designs, and registering it.
The whole thing, at once
A complete, working custom block. Read it once, then read the sections underneath for what each part is doing.
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
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 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
showIf line doing its work.
The definition
| Field | Required | What it does |
|---|---|---|
type | yes | Unique id. Stored in every block instance — treat it as permanent once documents exist. |
labelKey | yes | Its name in the palette, as a label key. Never a literal string. |
category | yes | Which palette group: basic, brand, text, parties, business, data, products, media, proof, layout, custom. |
kind | yes | 'static' or 'databound'. |
defaultContent | yes | What a freshly dropped block says. |
defaultStyle | yes | Layer 2 of style resolution. |
Component | yes | The canvas renderer. |
descriptionKey | — | Helper text in the palette. |
icon | — | A React node. |
contentSchema / styleSchema | — | Declarative forms. Strongly preferred over custom editors. |
ContentEditor / StyleEditor | — | Escape hatch: a bespoke React editor. Needs a documented reason. |
tabs | — | Which inspector tabs appear: 'content', 'style', 'template'. |
layouts | — | The designs offered in the Designs tab. |
maxInstances | — | Cap how many can exist in one document. |
requiresFeature | — | Gate 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.
Every variant shares these keys:
Every field type
| Type | Extra keys | Renders |
|---|---|---|
text | placeholderKey, multiline, rows, labelled, activeKey | A text input, or a textarea with multiline. |
number | min, max, step, unit | A numeric input with its unit shown. |
boolean | — | A switch. Never a checkbox — there is exactly one boolean control in the package. |
select | options: [{ value, labelKey }] | A dropdown. |
font | — | A typeface picker fed by your fonts prop. Empty means "follow the document". |
color | — | A colour picker. |
spacing | — | A four-sided spacing control. |
align | — | Start / center / end. |
image | — | Upload or pick from the library; stores a path. |
list | itemFields, addLabelKey | A repeatable group — table rows, gallery items. |
columns | — | Column visibility and order, for table-like blocks. |
group | fields, collapsed, flat | A section. flat: true means the children write top-level keys rather than a nested object. |
custom | Component | Your 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.
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
block | The raw block — id, type, layoutId. Rarely needed. |
content | Your content, already interpolated: {{ tokens }} have been replaced. |
style | Fully resolved style. All four layers already merged. |
ctx | The render context — see below. |
style arrives finished. Reading block.overrides in a renderer means
re-implementing precedence, and it will drift.
The render context
| Member | Use it for |
|---|---|
ctx.tokens | Document-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.documentType | For text you derive rather than text a user typed. |
ctx.readOnly | Hide editing affordances. |
ctx.printing | True 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
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
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
$, not ₹, not €, not in the renderer and not in a
design thumbnail. Use ctx.formatMoney().
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.