Docs / Developers

Creating a custom widget

Extend AbstractWidget, register it, done.

A widget only has to implement two things: get_name() (a unique type string, stored in every page that uses it) and get_title() (the label shown in the widget library). Everything else — icon, category, content controls, and render() — has a sensible default or is where you add your widget's own behavior.

Style, Layout, and Advanced tabs (spacing, color, typography, borders, responsive visibility, Custom CSS, Display Conditions) are handled generically by Craftor for every widget — you only ever build the Content tab.

php
final class Acme_Callout_Widget extends \Craftor\Widgets\AbstractWidget {

	public function get_name(): string {
		return 'acme_callout'; // prefix it — must never collide with a built-in widget
	}

	public function get_title(): string {
		return 'Callout';
	}

	public function get_content_controls(): array {
		return array(
			array( 'name' => 'heading', 'type' => 'text', 'label' => 'Heading', 'default' => 'Hello' ),
			array( 'name' => 'message', 'type' => 'textarea', 'label' => 'Message' ),
		);
	}

	public function render( \Craftor\Rendering\Element $element, string $children_html, string $css_class ): string {
		$heading = (string) ( $element->settings['heading'] ?? '' );
		$message = (string) ( $element->settings['message'] ?? '' );

		return sprintf(
			'<div class="%1$s"><h3>%2$s</h3><p>%3$s</p></div>',
			esc_attr( $css_class ),
			esc_html( $heading ),
			esc_html( $message )
		);
	}
}

add_action( 'craftor/register_widgets', function ( $registry ) {
	$registry->register( new Acme_Callout_Widget() );
} );

Declare widget classes that extend a Craftor base class only after confirming Craftor is loaded (a class_exists() check, or inside a callback on plugins_loaded/craftor/register_widgets) — never as a bare `class Foo extends ...` at the top level of your plugin file. WordPress loads active plugins in an unspecified order, so a top-level extends can fail if your file happens to load before Craftor's. See "The example add-on plugin" for the full pattern.

Rendering in the editor vs. the frontend

There's only one render path: render() runs for a real visitor's page load, for the REST endpoint that powers the editor's Live Preview canvas, and nowhere else — the exact same HTML string goes to both. You never write separate editor-mode markup.

The one exception is an editor-only empty-state placeholder — e.g. "Open the Content panel to enter a shortcode" shown only while editing, never to a real visitor. Two protected helpers on AbstractWidget handle this:

php
public function render( \Craftor\Rendering\Element $element, string $children_html, string $css_class ): string {
	$message = (string) $element->get_setting( 'message', '' );

	if ( '' === trim( $message ) && self::is_canvas() ) {
		// Only ever shown inside the editor canvas — a real page load
		// with an empty message just renders nothing below instead.
		return self::editor_placeholder( 'dashicons-megaphone', 'Callout', 'Enter a message in the Content panel' );
	}

	return sprintf( '<div class="%s">%s</div>', esc_attr( $css_class ), esc_html( $message ) );
}

Widget-specific JavaScript

If your widget needs client-side behavior (a slider, tabs, anything interactive), load it via get_assets() so it's only enqueued on pages that actually use the widget — but there's one thing every built-in interactive widget (Accordion, Tabs, Slider, Counter, Mega Menu…) does that yours needs too: re-run initialization after a `craftor:content-updated` event.

Inside the editor's Live Preview canvas, the whole page's HTML is replaced on every edit — any JS that attached listeners to the old DOM needs to re-attach to the new one. Craftor dispatches craftor:content-updated on document right after that replacement happens; the frontend never fires it, so it's a no-op cost there.

php
public function get_assets(): array {
	return array( 'js' => array( 'callout.js' ) );
}
js
( function () {
	'use strict';

	function init() {
		document.querySelectorAll( '[data-acme-callout]' ).forEach( function ( el ) {
			if ( el.dataset.acmeMounted ) return; // guard against double-init
			el.dataset.acmeMounted = '1';

			// ...attach your widget's interactive behavior here...
		} );
	}

	init();

	// The Live Preview canvas fully replaces the page's HTML on every
	// edit — re-run init() so newly rendered instances get wired up too.
	document.addEventListener( 'craftor:content-updated', init );
} )();