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:
What an Element IS
Every element type is a class that subclasses BaseElement (src/includes/BaseElement.js). The contract:
getData()returns the JSON shape that goes into save (the inverse ofparse).static parse(json)rehydrates an instance from that JSON (the inverse ofgetData)._doRender()paints the live canvas — setsthis.domNode.innerHTMLfrom the element's current state.constructor(...)accepts the per-instance args (typically just the format-bearing fields).
Step 1 — Subclass BaseElement
formatsis the engine's own field — we don't subclass-own it; just pass through what the controls write.target_iso+labelare the element's domain attributes.requiredTemplateKeysdeclares which keys the EJS template MUST receive — the engine's pre-render guard rejects a render if any are missing (regression-scar inBUILDER.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:
- The constructor is mapped at
ELEMENT_REGISTRY['CountdownElement']soparse()works for arbitrary JSON. - The class is published as
window.CountdownElementso 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/:
- 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:
Step 4 — Wire the live tick
The template emits [data-bjs-countdown] nodes. A page-level setInterval updates them:
Step 5 — Add settings-panel controls
The buyer needs to pick the target date + the label. Override getControls():
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:
- Insert a Countdown into the canvas (programmatically or via a Widget — see Build a custom Widget).
- Edit the Label in the settings panel.
- Call
builder.getData()in DevTools — confirm your label is in the JSON. - Call
builder.load(builder.getData(), themeTemplates, themeConfigData, mediaUrl)to round-trip. - 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
CountdownElementclass extendingBaseElementwith the 4 mandatory hooks. - ✅
Builder.registerElement('CountdownElement', CountdownElement)called beforebuilder.load(). - ✅
Countdown.template.htmlshipped in your theme + listed inindex.json. - ✅ A page-level
setIntervalwalking 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 BEFOREbuilder.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 useswith(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.jsfor 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.