Skip to content

Custom blocks

Custom blocks are registered exactly like the built-ins and participate in drag/drop, the inspector, persistence, data binding, theming, preview and PDF output. There is no privileged path and you never fork the product.

ts
import { defineBlock, cssText, toNumber } from "@broadpaper/core";

export const riskProfileBlock = defineBlock<{ score: string; profile: string; showDescription: boolean }>({
  type: "acme.risk-profile",           // namespace your types
  name: "Risk profile",
  description: "Risk score on a 1–10 scale",
  category: "custom",
  icon: "shield",                      // lucide icon name, or an inline <svg> string
  license: "pro",                      // optional tier gate
  defaultProps: { score: "client.riskScore", profile: "client.riskProfile", showDescription: true },
  defaultFlow: { keepTogether: true },
  styleGroups: ["spacing", "background", "border", "flow"],
  inspector: [
    {
      title: "Risk profile",
      fields: [
        { kind: "binding", prop: "score", label: "Score", valueType: "number" },
        { kind: "binding", prop: "profile", label: "Profile name", valueType: "string" },
        { kind: "toggle", prop: "showDescription", label: "Show description" }
      ]
    }
  ],
  validate: ({ props }) => (props.score ? [] : [{ severity: "warning", code: "missing-required", message: "Bind a score" }]),
  render: ({ h, css, props, theme, mode, node }) => {
    const score = toNumber(props.score) ?? 0;
    return h("div", { class: "acme-risk", style: cssText({ ...css, border: `1px solid ${theme.colors.border}` }) },
      h("strong", null, `Risk profile: ${props.profile}`),
      h("div", { style: { display: "flex", gap: "3px" } }, Array.from({ length: 10 }, (_, i) => h("div", { style: { flex: "1", height: "12px", background: i < score ? theme.colors.primary : theme.colors.border } }))),
      mode === "design" && node.bindings.score ? h("small", null, `bound to ${node.bindings.score.label}`) : null
    );
  }
});

Register it:

tsx
<ReportDesigner blocks={[riskProfileBlock]}  />
// or
designer.registerBlock(riskProfileBlock);

Inspector fields

Declare fields and the inspector is generated with the same controls the built-ins use:

text (with {{ token }} insertion) · textarea · richtext · number · toggle · select · segmented · color (theme tokens + custom) · font · textStyle · binding (single field, typed) · collection (a list) · expression · image · condition (rule builder) · columns · series · keyValues · list (repeating sub-form) · custom (your own UI via reactInspector / angularInspector).

Fields can be conditional (when: (props) => …) and grouped into collapsible sections (collapsed: true on a section that holds settings people configure once).

Four of the field options take a function of the block's props, because the right wording often depends on how the block is configured. Hard-coding it produces help text that is wrong for every block that was set up differently — a table whose rows are called h should not be told to write row.name.

ts
// Help text generated from the alias the designer actually chose.
{ kind: "text", prop: "alias", label: "Row name",
  description: (p) => `The name each row goes by in column values, e.g. ${p.alias || "row"}.name` }

// Options built from a sibling prop, so a rule picks a column by its heading
// rather than by an id the designer has never seen.
{ kind: "select", prop: "columnId", label: "Column",
  optionsFrom: (p) => (p.columns ?? []).map((c) => ({ value: c.id, label: c.header })) }

// How a collapsed item in a `list` describes itself. Without this the list
// falls back to whatever field looks like a name, which for rules keyed by
// column id meant showing "c5".
{ kind: "list", prop: "conditionalFormats", label: "Rules", itemLabel: "Rule",
  itemSummary: (rule, i, props) => `${columnName(rule.columnId, props)} · when ${rule.condition}`,
  fields: [ /* … */ ] }

Labels longer than about seventeen characters move above their control rather than being truncated, so there is no need to abbreviate a label to make it fit.

Bindings

Binding-kind fields are evaluated for you before render runs: props.score is already the number. node.bindings.score tells you the expression and a human label so design mode can show a chip. For per-item evaluation (a list you aggregate yourself) declare bindings explicitly with perItem: true and use the resolve hook's evaluateIn(expr, vars).

Render contract

render returns a VNode built with h(). It must be pure: no DOM access, no timers, no fetches. That is what lets the same function run in the editor, the preview and both PDF backends — a block never learns which one is rendering it. Text is escaped automatically; you cannot inject HTML by accident. ctx.css holds the resolved box/text styles from the cascade; ctx.theme is the concrete theme; ctx.text("prop") renders a token-bearing string prop; ctx.children holds rendered children for containers.

Containers: set container: { accepts: ["@blocks", "@rows"] } and render ctx.children inside a .bp-stack with data-dropzone: node.nodeId so drag/drop works.

Splitting: leaf blocks are atomic; set split: "children" for containers. Blocks whose inner DOM is a table.bp-table can declare split: "rows" and get repeated headers for free; text-like blocks can declare split: "lines" and mark the text container with data-part="text".

In the PDF

With @broadpaper/forme there is nothing to do: you pass the same registry you gave the editor, and the VNode your block returns is translated for the engine like any other. SVG your block emits is handled too — group transforms are flattened and text inside it is drawn as real text, because the engine does neither itself.

The Chromium service is different, because it runs your block's render function inside a browser page. Bundle it with your usual bundler as an IIFE that calls BroadPaper.registerBlocks([...]) and pass the source as customBlocksScript (or configure it once on the server). The repository's PDF tests do this with esbuild in a few lines.

Migrations

Bump version and implement migrate(props, fromVersion) when a block's props shape changes; saved templates keep working.