Skip to content
Extend the engine C2 5 min read Customisation Copy-along · 45 min

Build a custom Control

Subclass an existing Control, wire a brand picker — 45 minutes.

In Build a custom Element you wired your Countdown's settings panel from existing controls (TextControl). Sometimes the shipped catalogue isn't enough — your brand needs a colour picker that pre-loads brand swatches, a slider that emits px AND em, a dropdown whose options depend on another field. Then you author a Control. Time budget: 45 minutes.

What a Control IS

A Control is a settings-panel widget that mutates one or more attributes / formats of the currently-selected element. The shipped catalogue (≈ 40 control types — TextControl, SelectControl, ColorPickerControl, DimensionControl, BorderControl, …) lives in src/includes/<Name>Control.js.

Two paths to author a new one:

  • Subclass an existing Control — recommended. You get free wiring (label, container DOM, focus, the _applyValue syncing). Start here.
  • Subclass BaseControl directly — only when the existing controls have a fundamentally different input model than what you need (rare).

This doc walks the canonical pattern: a Brand Color Picker that subclasses ColorPickerControl and adds 6 quick-access brand swatches.

Pre-flight

Confirm the demo runs and the canonical example renders:

Open <http://localhost:8000/examples/extensions/3-custom-control/>. You should see a Brand Badge element with a custom colour picker exposing 6 preset brand swatches. Every step below reproduces what's running there.

Step 1 — Subclass

The constructor accepts the same `(label, value, onChange)` signature as the parent — so the buyer can drop your control into any element's `getControls()` without learning new wiring. Extra options ride on a final `options` arg.

Step 2 — Override render()

The shipped ColorPickerControl.render() builds the standard label + colour swatch + popover. We extend it: call super first, then append the brand-swatches strip.

Two patterns to lock in:
  1. Always call super.render() first. The parent builds the DOM your additions live inside.
  2. Always route value changes through _applyValue — the canonical entry point that syncs the control's internal state (the visible hex input, the swatch preview, the parent's value property, AND the onChange callback the buyer passed in). The footgun: there's also _notify(value) which is the change-event path; it fires the callback but DOESN'T sync the visible UI. Use _applyValue. (W6.C.13 lesson.)

Step 3 — Wire it into an Element

You don't register custom Controls anywhere — they're instantiated inline inside an Element's getControls():

The buyer sees one extra widget in the settings panel — labelled "Brand color", showing the standard color picker UI plus your 6-swatch strip below. Click any swatch and the canvas updates instantly via `notifySyncListeners()`.

Step 4 — There's no Builder.registerControl()

Custom Controls aren't registry-based today (W6.C.13 lesson). You instantiate them inline inside getControls(). That works for any project where the Control's constructor args are statically known.

A future engine API (Builder.registerControl(name, klass)) would unlock JSON-described custom panels — buyers would author getControls() in JSON instead of JS — but that's a CD-2 follow-up, not shipped today. The roadmap for engine-side improvements is tracked in the BuilderJS repo's open issues.

Step 5 — Test

  1. Open the builder with a sample that uses your BrandBadgeElement.
  2. Click the badge → settings panel shows your BrandColorPickerControl.
  3. Click any of the 6 swatches → the canvas updates with the new colour.
  4. Open DevTools → Console: builder.getData(). Confirm your brand_color value is the hex you clicked.
  5. Reload the page → the badge re-renders with the saved colour (round-trip).

If step 3 paints the canvas but step 5 forgets the colour, your getData() is missing the brand_color field. Re-read Build a custom Element — every domain attribute must be in getData() AND parse().

Did you make it?

You should now have:

  • ✅ A BrandColorPickerControl class subclassing ColorPickerControl.
  • ✅ Override render() that calls super.render() first, then appends the brand strip.
  • ✅ Each swatch click routes through this._applyValue(hex) — NOT _notify(hex).
  • ✅ The custom Control instantiated inline inside an Element's getControls().
  • ✅ The round-trip: setting a swatch persists across save / load.

If a step didn't work:

  • 🩹 Click on swatch updates the visible hex input but the actual saved value is stale → you used _notify instead of _applyValue. They look similar; _applyValue is the syncing entry.
  • 🩹 Custom Control doesn't render at all → confirm you called super.render() first. The parent builds the container your popover element lives inside.
  • 🩹 I want to subclass BaseControl directly, not an existing Control → that's the rare path; BaseControl is mostly an interface (label, value, container, callback). Read src/includes/BaseControl.js line by line.

What's next?

  • Build a custom Widget — palette items the buyer drags onto the canvas.
  • 🔗 examples/extensions/3-custom-control/ — runnable Brand Color Picker end-to-end.
  • 🔗 src/includes/ColorPickerControl.js — canonical parent + the _applyValue vs _notify contract.
  • 🔗 docs/core/CONTROL_DEFINITION.md — the full control catalogue (≈ 40 shipped types).