Extensions · 1 of 5

Custom Widget — subclass BaseWidget, register, ship

Three engine touchpoints — `extends BaseWidget` (with `getName` / `getIcon` + a constructor that builds `this.block`), `widgetsBox.addWidget(new MyWidget(), { group })`, and `widgetsBox.render()`. Custom palette items drag onto the canvas the same way stock widgets do; child elements become inline-editable from first drop.

Walkthrough

  1. Subclass BaseWidget — getName, getIcon, build this.block

    BaseWidget exposes three override hooks: getName() (palette label, REQUIRED — no default), getIcon() (Material Symbol Rounded name, defaults to 'extension'), and the constructor where you build the this.block shape that drops onto the canvas.

    this.block = new BlockElement('Block') is auto-created by the parent constructor — your constructor calls super() first, then appends child elements via this.block.appendElements([heading, paragraph, ...]). The block + child elements use the engine's standard Element classes (HeadingElement, PElement, ImageElement, etc.) so every standard format / inline-edit / drag-drop hook just works.

    Engine globals exposed by dist/builder.js: BaseWidget · BlockElement · HeadingElement · PElement · ImageElement · ButtonElement · plus the full element catalogue (see docs/core/ELEMENT_DEFINITION.md).

    JavaScript
    class CalloutWidget extends BaseWidget {
      // Palette label — REQUIRED (no default).
      getName() { return 'Callout'; }
    
      // Material Symbol Rounded name. Defaults to 'extension'.
      getIcon() { return 'campaign'; }
    
      constructor() {
        super();  // creates this.block = new BlockElement('Block')
    
        // Build the shape the widget drops onto the canvas: a Heading +
        // a Paragraph nested inside the block.
        const heading   = new HeadingElement('Heading', 'h2', '✨ Pro tip');
        const paragraph = new PElement('Paragraph',
          'Drop this into any layout to highlight a key insight.'
        );
        this.block.appendElements([heading, paragraph]);
      }
    }
  2. Register via widgetsBox.addWidget + render

    builder.widgetsBox is the live palette manager. addWidget(instance, options) registers a widget; options.group places it under a named heading in the palette ("Custom" / "Layout" / "Marketing" — anything you want). Multiple custom widgets share the same group string.

    After all widgets are registered, call widgetsBox.render() ONCE to paint the palette. Calling render mid-load() is wasteful — the engine's load() already paints once when the palette mount completes; tucking addWidget() calls into the load callback (or right after load resolves) means a single render covers all your customizations.

    The widget instance you register IS the palette item (it owns the palette DOM through render()). On drop, the engine clones the underlying block via renderBlock() (BaseWidget default: BlockElement.parse(this.block.getData())) — so dropping the same widget multiple times produces independent copies.

    JavaScript
    // Mount + load the engine first; widgets register AFTER load() so the
    // palette mount is ready to receive them.
    const builder = new Builder({
      mainContainer:     '#MainContainer',
      widgetsContainer:  '#WidgetsContainer',
      settingsContainer: '#SettingsContainer',
    });
    
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
      // Register inside the load callback — guarantees widgetsBox exists.
      builder.widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' });
      // Add more custom widgets in the same group:
      // builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Custom' });
      // builder.widgetsBox.addWidget(new HeroBannerWidget(),  { group: 'Custom' });
    
      // ONE render() pass paints all custom widgets at once.
      builder.widgetsBox.render();
    });
  3. Drag from palette → drop on canvas → inline-edit

    Once registered + rendered, the widget drags exactly like a stock widget. Drop targets are any droppable container (PageElement, BlockElement's child slots, GridElement's cells). The engine handles the drag-drop overlay, drop-zone visualisers, container highlight, and post-drop selection out of the box.

    The dropped block + child elements are LIVE Element instances — heading text is inline-editable on click, paragraph text is inline-editable, the block has its own background / padding settings panel, every nested element gets its own settings panel when selected. No additional wiring needed.

    To distinguish your widget's palette item in the DOM (for spec / styling): the engine adds widget-class="" to the palette item's root div. Your stylesheet can target [widget-class="CalloutWidget"] .widget-icon-box { background: var(--brand-accent); } for per-widget polish.

    JavaScript
    // Custom CSS to style your widget in the palette (optional).
    // The engine writes widget-class="<ClassName>" on each palette item's
    // root div, giving you a stable hook for per-widget styling.
    
    // In your stylesheet:
    // [widget-class="CalloutWidget"] .widget-icon-box {
    //   background: rgba(245, 158, 11, 0.12);
    //   color: #d97706;
    // }
    
    // Buyer-side composition — register multiple widgets up-front,
    // each in their own group for natural palette organization.
    builder.widgetsBox.addWidget(new CalloutWidget(),     { group: 'Custom' });
    builder.widgetsBox.addWidget(new HeroBannerWidget(),  { group: 'Custom' });
    builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Marketing' });
    builder.widgetsBox.addWidget(new PricingTableWidget(),{ group: 'Marketing' });
    builder.widgetsBox.render();  // ONE render covers all four.

Live demo

demo-mini-builder--rich-3col-header
Custom Widget — drag Callout from the left palette onto the canvasRegistered ✓

The whole snippet

Click Copy on any file to grab it byte-identical, or Expand to read inline (capped at ~520 px so the page stays scannable). Multi-file snippets surface a side-by-side grid — copy only the files you need.

<?php
/**
 * snippet.php — Custom Widget by subclassing `BaseWidget`.
 *
 * Drop alongside `dist/` + `themes/default/`, run a PHP server.
 * The CalloutWidget appears in the left palette under "Custom"; drag
 * onto the canvas to drop a `BlockElement` carrying a `HeadingElement`
 * + `PElement`. Both child elements are inline-editable on click.
 *
 * Class name `CalloutWidget` matches the live demo on
 * /examples/extensions/1-custom-widget/ AND Steps 1-3 of the
 * walkthrough (BUILDER.md RULE H).
 */
declare(strict_types=1);

require_once __DIR__ . '/../../../backend/_lib/ThemeRegistry.php';

$bundle = (new \DemoBuilder\ThemeRegistry(__DIR__ . '/../../../themes'))
    ->resolveBundle('default', 'master/sample/email/Minimal');
?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Custom Widget — subclass BaseWidget, register, ship</title>
    <link rel="stylesheet" href="/dist/builder.css">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=block">
    <style>
        body { margin: 0; font-family: system-ui, sans-serif; display: grid; grid-template-rows: 56px 1fr; height: 100vh; }
        .my-header { display: flex; align-items: center; gap: 12px; padding: 0 16px; background: #f8f9fa; border-bottom: 1px solid #e5e7eb; }
        .my-header__title { font-weight: 600; font-size: 14px; }
        .my-header__hint { margin-left: auto; font-size: 12px; color: #6b7280; }
        .my-builder-host { display: grid; grid-template-columns: 200px 1fr 240px; min-height: 0; }
        .my-host-slot { background: #f8f9fa; padding: 8px; overflow: auto; font-size: 12px; }
        .my-canvas { overflow: auto; background: #fff; }

        /* Per-widget palette polish — engine writes widget-class="CalloutWidget"
         * on the palette item's root div. We tint its icon-box amber so the
         * Callout widget visually owns its identity in the palette. */
        [widget-class="CalloutWidget"] .widget-icon-box {
            background: rgba(245, 158, 11, 0.16);
            color: #d97706;
            border-radius: 8px;
        }
        [widget-class="CalloutWidget"]:hover .widget-icon-box {
            background: rgba(245, 158, 11, 0.28);
        }
    </style>
</head>
<body>

<header class="my-header">
    <span class="my-header__title">Custom Widget — Callout</span>
    <span class="my-header__hint">Drag <strong>Callout</strong> from the left palette → drop on canvas</span>
</header>

<div class="my-builder-host">
    <div id="MyWidgets"  class="my-host-slot"></div>
    <div id="MyCanvas"   class="my-canvas"></div>
    <div id="MySettings" class="my-host-slot"></div>
</div>

<script>
    window.THEME_JSON        = <?= json_encode($bundle->themeJson,      JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_TEMPLATES   = <?= json_encode($bundle->themeTemplates, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.THEME_CONFIG_DATA = <?= json_encode($bundle->configData,     JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
    window.MEDIA_URL         = <?= json_encode($bundle->mediaUrl,       JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
</script>

<script src="/dist/builder.js"></script>

<script>
    // 1) Subclass BaseWidget — getName / getIcon + a constructor that
    //    builds `this.block` (the BlockElement that drops on the canvas).
    class CalloutWidget extends BaseWidget {
        getName() { return 'Callout'; }
        getIcon() { return 'campaign'; }
        constructor() {
            super();  // creates this.block = new BlockElement('Block')
            const heading = new HeadingElement('Heading', 'h2', '✨ Pro tip');
            const paragraph = new PElement('Paragraph',
                'Drop this into any layout to highlight a key insight or call out an important detail for your readers.'
            );
            this.block.appendElements([heading, paragraph]);
        }
    }

    const builder = new Builder({
        mainContainer:     '#MyCanvas',
        widgetsContainer:  '#MyWidgets',
        settingsContainer: '#MySettings',
    });

    // 2) Register inside the load callback — guarantees widgetsBox is mounted.
    builder.load(THEME_JSON, THEME_TEMPLATES, THEME_CONFIG_DATA, MEDIA_URL, () => {
        builder.widgetsBox.addWidget(new CalloutWidget(), { group: 'Custom' });
        // Add more custom widgets here, all under the same `group` for one heading:
        // builder.widgetsBox.addWidget(new TestimonialWidget(), { group: 'Custom' });

        // 3) ONE render() pass paints all the additions at once.
        builder.widgetsBox.render();
    });
</script>

</body>
</html>

Notes

Where the engine globals live. dist/builder.js exposes window.BaseWidget, window.BlockElement, window.HeadingElement, window.PElement, window.ImageElement, window.ButtonElement and the full element catalogue (see source src/builder.js lines 261-401). ESM consumers can import { BaseWidget, BlockElement, HeadingElement, PElement } from 'builderjs' instead — same surface, no globals.

Why register inside the load callback? builder.widgetsBox is created during load() setup. Calling addWidget() BEFORE load() resolves means widgetsBox may be undefined. The 5th arg of load(themeJson, themeTemplates, themeConfigData, mediaUrl, callback) is a callback that fires after the palette mount completes — that's the canonical hook for buyer customizations.

Group string is a free-form key. The options.group argument groups widgets under a heading in the palette. Use any string — "Custom", "Marketing", "Forms", "Pricing", "AI". Widgets sharing the same group render under the same heading; new groups append at the bottom of the palette in registration order.

The widget IS the palette item; renderBlock() drops the canvas instance. When the buyer drags the palette item, the engine calls widget.renderBlock() to clone the block — BaseWidget's default implementation parses this.block.getData() via BlockElement.parse, producing a fresh BlockElement instance with the same shape as the widget's prototype. Each drop is independent — editing one Callout instance doesn't affect the palette item or other dropped instances.

Per-widget styling via widget-class attr. The engine writes widget-class="" on the palette item's root div. Your stylesheet can target it for per-widget polish — e.g. [widget-class="CalloutWidget"] .widget-icon-box { background: var(--brand-accent); }. The live demo above uses this hook to tint the Callout widget's icon box amber + match the "campaign" icon's marketing connotation.

What about element-level extensions? Custom widgets are about adding NEW palette items that drop existing element types in a pre-shaped block. To extend the engine with a brand-NEW element type (e.g. a Map element, a Chart element), you subclass BaseElement + register via Builder.registerElement(name, klass) — see extensions/2-custom-element/ (W6.C.12, ships next). For custom controls in the settings panel, see extensions/3-custom-control/ (W6.C.13). For locale packs, see extensions/4-i18n-locale-pack/ (W6.C.14).