Skip to content
Extend the engine C3 5 min read Customisation Copy-along · 30 min

Build a custom Widget

Add a palette item — drag, drop, 30 minutes.

You've built a custom Element (C1) and a custom Control (C2). The third extensibility surface is the Widget — the palette item the buyer drags from the left rail onto the canvas. A widget bundles "the Element class + its preset content + a palette icon" into one drag-droppable thing. Time budget: 30 minutes.

This is the smallest of the three extension surfaces. If your project only needs one new component, a Widget is often the right shape — it's faster to ship than a custom Element + Control combo.

What a Widget IS

A Widget is a class that wraps a pre-built block of content + a palette icon + a label. The buyer drags the icon, drops a fully-formed block onto the canvas. The shipped catalogue has 20+ widgets — Heading, Paragraph, Image, Button, Divider, List, Icon, Social, RSS, …. Each is ≈ 50-100 LOC.

Subclass BaseWidget:

The 3 mandatory hooks:
  • constructor() — build this.block, the BlockElement (or any single Element subclass) the buyer drops onto the canvas.
  • getName() — palette label. Routes through I18n.t('widgets.<key>') for translated builds (see i18n).
  • getIcon() — Material Symbols Rounded icon name (campaign, image, format_list_bulleted, etc.). Browsers render the ligature via the font.

Pre-flight

Confirm the demo runs:

Open <http://localhost:8000/examples/extensions/1-custom-widget/>. You should see a Callout palette item in the left rail. Drag it onto the canvas — a pre-built heading + paragraph block lands. Every step below reproduces this.

Step 1 — Subclass BaseWidget

The `this.block` you build is the canonical **template content** — when the buyer drags the icon onto canvas, the engine clones this block, gives the clone its own ids, and inserts it. The clone is fully editable (controls + drag + delete) — your prebuilt content is just the seed.

Step 2 — Register with the palette

The widgets palette doesn't auto-discover — you call addWidget() after Builder boots. Inside the load callback so the engine has registered its stock widgets first:

Two non-obvious points (W6.C.B.3 + W6.C.14 lessons):
  • The palette is empty by default unless you call addWidget() — even for the stock widgets like Heading + Image. The shipped demo/builder.php registers them in its boot block; if you're authoring your own host page, you'll need to register every widget you want surfaced.
  • render() after every batch of addWidget calls. Otherwise the palette DOM doesn't update until the next mutation.

The group option ('Custom' here) becomes the section heading in the palette UI; the buyer sees groups like "Layout", "Content", "Forms", "Custom".

Step 3 — Per-widget palette polish (optional)

If you want a custom palette icon style (a rounded chip vs the plain icon), use the [widget-class="<ClassName>"] styling hook:

The engine renders each palette item with `widget-class="<className>"` as an attribute, giving you a deterministic CSS hook per widget without touching the engine's stylesheet.

Step 4 — i18n (optional)

If your build supports multiple languages, route the palette label through I18n.t():

Add the key to your locale dict:
There are **two i18n key namespaces** (W6.C.14 lesson): palette labels route through `widgets.*`; canvas + panel labels route through `elements.*`. A locale pack must translate both — translating only one and you get a half-translated UI. [Multi-language locale packs](?doc=14-i18n) covers the full story.

Step 5 — Test

  1. Open the builder. The Callout widget appears under "Custom" in the palette.
  2. Drag it onto the canvas. The pre-built block lands; the clone is editable.
  3. Edit the heading. Save. Reload. The edited content survives.
  4. Drag a second Callout next to the first. Each is independent (no shared state).

If step 1 fails (the widget isn't in the palette), confirm addWidget was called inside the load() callback (or after load()'s promise resolves). If you call it before load() runs, the palette renders empty because widgetsBox doesn't exist yet.

Did you make it?

You should now have:

  • ✅ A CalloutWidget class subclassing BaseWidget with the 3 hooks (constructor, getName, getIcon).
  • this.block built in the constructor with the prebuilt content.
  • ✅ Widget registered in the load callback via widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' }).
  • widgetsBox.render() called after the batch.
  • ✅ Drag-drop working: dragging the icon drops a clone of this.block onto the canvas.

If a step didn't work:

  • 🩹 Widget not appearing in palette → it was registered too early (before load() callback) or widgetsBox.render() wasn't called.
  • 🩹 Drag does nothing → confirm widgetsContainer constructor option points at your real DOM element. If null, the widgets box doesn't render at all.
  • 🩹 Drop creates a block but the heading text is wrong → confirm your this.block's HeadingElement constructor received the right args. The engine mostly takes the constructor verbatim.
  • 🩹 Palette items load but no styling on your custom one → use the [widget-class="…"] selector, not a class on the widget element directly (the engine's DOM ships with widget-class as an attribute).

What's next?

  • Custom overlays (advanced) — per-element on-canvas affordances (resize handles, drag indicators, image crop).
  • 🔗 examples/extensions/1-custom-widget/ — the runnable Callout end-to-end.
  • 🔗 src/includes/BaseWidget.js — the parent class.
  • 🔗 src/includes/HeadingWidget.js + ParagraphWidget.js — engine-shipped widgets for reference.

Stuck?

  • 🐛 grep src/includes/<Name>Widget.js — the catalogue of shipped widgets is your library of working examples.
  • 🐛 file an issue with: your CalloutWidget class, the boot block calling addWidget, console errors.