Skip to content
Extend the engine C1 6 min read Customisation Copy-along · 60 min

Build a custom Element

Subclass BaseElement, ship a Countdown — 60 minutes end-to-end.

You've shipped a sample (B3) and a theme (B4). Now you'll go past the theme layer and add a new element type to the engine — a Countdown timer the buyer can drag into any page. By the end of this doc you'll have a working CountdownElement with its own template, its own settings panel controls, its own JSON shape — and the round-trip rule still holds. Time budget: 60 minutes.

Pre-flight

Confirm the dev server boots and the live builder loads at /builder.php. Then open the canonical example you'll mirror:

Open <http://localhost:8000/examples/extensions/2-custom-element/>. The page renders a working Countdown — every step in this doc reproduces what's running on that page.

What an Element IS

Every element type is a class that subclasses BaseElement (src/includes/BaseElement.js). The contract:

The four mandatory hooks pair like this:
  • getData() returns the JSON shape that goes into save (the inverse of parse).
  • static parse(json) rehydrates an instance from that JSON (the inverse of getData).
  • _doRender() paints the live canvas — sets this.domNode.innerHTML from the element's current state.
  • constructor(...) accepts the per-instance args (typically just the format-bearing fields).

Step 1 — Subclass BaseElement

The shape here is the canonical CD-1 pattern from W6.C.12:
  • formats is the engine's own field — we don't subclass-own it; just pass through what the controls write.
  • target_iso + label are the element's domain attributes.
  • requiredTemplateKeys declares which keys the EJS template MUST receive — the engine's pre-render guard rejects a render if any are missing (regression-scar in BUILDER.md's element-dev rules).

Step 2 — Register the class

For the engine's element registry to recognise the type, register it after Builder boots:

Two things happen:
  1. The constructor is mapped at ELEMENT_REGISTRY['CountdownElement'] so parse() works for arbitrary JSON.
  2. The class is published as window.CountdownElement so user-authored code can reach it.

Drop this line right after your Builder constructor call but before builder.load(…). If you load a page tree containing CountdownElement nodes before the registry knows about the class, the engine warns "unknown element type" and skips the node.

Step 3 — Author the template

Every element renders via an EJS template the active theme owns. Add Countdown.template.html under themes/default/:

Two non-obvious rules from W6.C.12:
  • Visual rules MUST live INSIDE the EJS template body. Host-page <style> rules don't cross the iframe canvas boundary. If you write .my-countdown { … } in the host's <style>, it WILL render unstyled on canvas.
  • Inline style="…" is fine — it ships with the element when exported. Class-based styling needs to ship via the template's own <style> tag.

Add 'Countdown' to your theme's index.json templates array so the engine knows to load it:

(The first line shown for context; the second is the change.)

Step 4 — Wire the live tick

The template emits [data-bjs-countdown] nodes. A page-level setInterval updates them:

The iframe-walk is non-obvious until you trip over it: the engine sandboxes each block render in its own `<iframe>` for style isolation, so top-document `document.querySelectorAll('[data-bjs-countdown]')` returns nothing.

Step 5 — Add settings-panel controls

The buyer needs to pick the target date + the label. Override getControls():

Each Control receives a label, the current value, and a callback. The callback mutates the element's own state and calls `notifySyncListeners()` — that triggers a re-render with the new value.

The shipped controls catalogue (TextControl, SelectControl, ColorPickerControl, DimensionControl, …) lives in src/includes/; mix-and-match by what your element needs. To author a new Control type that doesn't exist yet, see Build a custom Control.

Step 6 — Test the round-trip

The whole point of the static parse() + instance getData() pair is round-trip integrity:

  1. Insert a Countdown into the canvas (programmatically or via a Widget — see Build a custom Widget).
  2. Edit the Label in the settings panel.
  3. Call builder.getData() in DevTools — confirm your label is in the JSON.
  4. Call builder.load(builder.getData(), themeTemplates, themeConfigData, mediaUrl) to round-trip.
  5. The Countdown still renders with your edited label, the timer still ticks.

If any step fails, the issue is one of: getData() missing a key, parse() not setting that key, or requiredTemplateKeys lying about what the template needs.

Step 7 — Validator awareness

The engine ships a JSON-shape validator at BuilderJsonStructureValidator. Custom elements aren't auto-listed in supportedLeafElements — the validator warns but doesn't block; the canvas renders fine. To silence the warning, append your class name to the validator's set or extend the validator subclass. Most projects accept the warning until they automate validation in CI.

Did you make it?

You should now have:

  • ✅ A CountdownElement class extending BaseElement with the 4 mandatory hooks.
  • Builder.registerElement('CountdownElement', CountdownElement) called before builder.load().
  • Countdown.template.html shipped in your theme + listed in index.json.
  • ✅ A page-level setInterval walking iframes to tick the timers.
  • ✅ A settings panel with at least one Control for label + target.
  • ✅ Round-trip: builder.load(builder.getData(), …) preserves the element.

If a step didn't work:

  • 🩹 Engine warns "unknown element type" → confirm Builder.registerElement('CountdownElement', CountdownElement) ran BEFORE builder.load(). The registry is consulted at load time.
  • 🩹 Element renders but settings panel is empty → confirm getControls() returns an array of Control instances, not raw DOM. The engine wires each Control's render slot for you.
  • 🩹 Visual styling missing on canvas → moved the rules from the host <style> into the EJS template's own <style> tag (host CSS doesn't cross iframe — W6.C.12 lesson).
  • 🩹 EJS error "ReferenceError: formatter is not defined" → drop any <%- formatter ? formatter(text) : text %> ternaries from your template; the engine's EJS uses with(locals) and trips on undefined identifiers BEFORE the ternary short-circuits. Either define a no-op formatter or remove the reference.

What's next?

  • Build a custom Control ⭐ — when the shipped controls don't cover your domain (brand color picker, range slider with units, dependent dropdowns).
  • 🔗 examples/extensions/2-custom-element/ — the runnable Countdown end-to-end with the iframe-tick pattern wired.
  • 🔗 src/includes/BaseElement.js — the canonical contract.
  • 🔗 src/includes/PElement.js + HeadingElement.js — engine-shipped element classes for reference shape.

Stuck?

  • 🐛 grep src/includes/<TypeName>Element.js for any shipped element matching what you want.
  • 🐛 the [data-bjs-countdown] iframe-walk trick is W6.C.12's lesson — re-read its DISCOVERIES entry for the long version.
  • 🐛 file an issue with: your full subclass, the EJS template, the warning text from console, the JSON getData() produces.