Tutorial: Adding a New Widget Type to the Page Builder

This tutorial walks through adding a brand-new widget type to the Page Builder from scratch. Widget types are content blocks that editors can place inside columns in the Layers Panel.

We will build a Callout widget — a styled box with a heading, a body paragraph, and a configurable accent color. By the end, the widget will:

  • appear in the Widget Picker with its own icon and color
  • have a settings dialog with content and style fields
  • render its own markup instantly in the editor canvas, with no server round trip
  • render correct, sanitized HTML on the real frontend
  • show a correctly labeled bar in the Layers Panel

For a systematic, key-by-key reference of every {type}.styles.json key used below (useSharedSections, ownFields, omitFields, showIf, cssProperty, etc.), see Style Field JSON Reference — this tutorial shows one worked example, that page documents the format itself.

What the finished widget looks like, rendered in the canvas or on the frontend (a plain-text mockup, not a real screenshot — there is no existing Callout widget to screenshot; this tutorial is what creates it):

┌───────────────────────────────────────────┐
│▐                                           │
│▐  Important Notice                        │  ← sanilwb_heading, <h4>
│▐                                           │
│▐  Add your callout message here.          │  ← sanilwb_body, <p>
│▐                                           │
└───────────────────────────────────────────┘
 ↑
 4px solid accent-color border-left (sanilwb_accent_color)

Follow every step in order.


Before You Start

Read WIDGET-ARCHITECTURE.md first

Read WIDGET-ARCHITECTURE.md (admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source) before writing any code. It documents the one shared Style tab section scheme (Layout, Size, Typography, Appearance, Spacing, Position) every widget draws from, the rule for when a widget renders in JS versus PHP/AJAX, how a Style field can be redirected to a different element than the default outer wrapper, and the link/hover tag pattern. Several of these encode real bugs already found and fixed once — skipping this risks re-introducing one in your new widget. This tutorial repeats the parts of it you need for Callout, but it is not a substitute for reading the whole thing.

The files you touch

A Page Builder widget type touches three new files you create (two of them together in their own subfolder), plus edits to a small, fixed set of existing files:

File What it does
admin/assets/js/src/page-builder/config/widgets/callout/callout.js (new file, in a new callout/ subfolder) Declares the widget's definition (icon, default values) and its buildTabs() settings-dialog function (Content tab only — see below)
admin/assets/js/src/page-builder/config/widgets/callout/callout.styles.json (new file, same callout/ subfolder) Declares the widget's entire Style tab as data — which shared sections it uses, plus its own fields — read identically by JS and PHP. See WIDGET-ARCHITECTURE.md's "The JSON-Driven Style Tab System" section for the full mechanism this tutorial only summarizes.
A new file in includes/shortcodes/ (e.g. class-sanilwb-callout-shortcode.php) Renders the widget's HTML on the real frontend, via the [sanilwb] shortcode

Every widget type has its own subfolder in config/widgets/ (heading/heading.js, button/button.js, ...) — there is no single monolithic config file. config/widgets/index.js imports every one of them and assembles the WIDGET_TYPES registry and the getWidgetDialogTabs() dispatcher; registering your new widget there is a two-line addition (an import + a registry/switch entry — see Step 1 and Step 2).

Every file already in includes/shortcodes/ is auto-loaded via a glob() loop in includes/class-sanil-website-builder.php — dropping in a new file is enough, no manual require needed. The only other PHP touch point for the frontend render is a single elseif branch you add to SANILWB_Shortcode_Handler::process_shortcodes() (includes/class-sanilwb-shortcode-handler.php) so the dispatcher routes your widget's type to the new class — see Step 5.

Restricting which builder contexts allow this widget (optional, but has a required second step if you use it)

A widget's definition can carry an optional contexts array — e.g. contexts: ['page', 'template', 'site_layout'] — naming which of the three Page Builder contexts (a real Page, a reusable Template, a Site Layout) it's allowed in. Leaving contexts off entirely (Callout's own example below does this) means "allowed everywhere," which is correct for most widgets. Only add it for a widget that fetches data tied to one specific request (like post_content or archive_title, which are ['page', 'site_layout']-only — see the Separation of Concerns rule in the root CLAUDE.md).

If you do add contexts, you must also add a matching entry to SANILWB_Ajax::WIDGET_CONTEXTS (includes/class-sanilwb-ajax.php) — a second, independent allowlist that mirrors the JS one. WidgetPickerDialog.jsx only stops a user from adding a disallowed widget through the UI; WIDGET_CONTEXTS is the server-side backstop that save_template() and save_site_layout() both check before a schema is ever saved. A widget type missing from this map resolves to an empty allowed-contexts array, which means the save is rejected for every context, not "allowed everywhere" the way you might expect from the JS side being permissive by default — the two sides default in opposite directions. The failure surfaces as wp_send_json_error(['message' => 'This layout contains a widget or feature that is not allowed here.']) (a 422) the moment the widget is saved inside a Template or Site Layout — a plain Page save never hits this check at all (save_metabox() doesn't call it), so testing only in a Page, like this tutorial's own "Testing It" section does, will not catch a missing entry here.

How the editor canvas actually renders a widget

The canvas is a React app, not an iframe of the live site — it needs to show your widget's markup the instant a field changes, without waiting on a full page reload. There are two ways a widget's HTML reaches the canvas, and JS rendering is the default expectation for a new widget, not the exception:

  1. JS rendering (default). admin/assets/js/src/page-builder/utils/renderWidgetPreview.js builds the widget's HTML directly in JavaScript from its saved field values — no server round trip at all. 19 of the 26 built-in widget types work this way today (heading, paragraph, image, button, post_title, archive_title, featured_image, excerpt, term_name, icon, video, social_icons, spacer, text_input, textarea, select, radio_group, checkbox, submit_button — see that file's JS_RENDERED_WIDGET_TYPES set). This is the correct default for any widget whose output can be computed from its own field values alone.
  2. PHP/AJAX rendering (fallback, only when genuinely required). post_content, template_reference, shortcodes, author, date, carousel_nav, and menu are the widget types that return null from renderWidgetPreview()CanvasWidgets.jsx's resolveWidgetHtml() then falls back to an AJAX call (sanilwb_pb_render_widget, handled by Sanil_Website_Builder_Public::ajax_render_widget()) that builds the real [sanilwb ...] shortcode string and runs it through do_shortcode() server-side. This path is required for post_content/shortcodes (arbitrary/unknown content only PHP can expand) and template_reference (renders a referenced template's own schema_json through the same PHP tree-walk a real page uses — see Dynamic Template Widget) — author, date, carousel_nav, and menu don't strictly need it the same way post_title/excerpt don't, they simply haven't been migrated to the JS path yet.

Callout has none of those three dependencies — its output is just its own field values — so it belongs in the JS-rendered group. Step 3 below adds it to renderWidgetPreview.js.

Both paths still need a real PHP shortcode class regardless. JS rendering only ever produces the editor's live preview — the actual published page has no React running on it, so PHP must independently be able to render the exact same widget on the real frontend. That is what Step 4's shortcode class is for. The two are not alternatives to each other; every widget needs the PHP class, and most widgets (Callout included) additionally get the JS renderer so the editor doesn't need a network request on every keystroke.

How field values flow from the dialog to the shortcode

Widget fields must have names that start with sanilwb_. When the user saves the settings dialog, all field values are stored in widget_values as a JSON string with the full sanilwb_* key names.

When the [sanilwb ...] shortcode string is built — in PHP, not JS — the sanilwb_ prefix is stripped from each key name: SANILWB_Frontend_Renderer::render_widgets() (includes/class-sanilwb-frontend-renderer.php, real frontend) and Sanil_Website_Builder_Public::ajax_render_widget() (public/class-sanil-website-builder-public.php, editor AJAX fallback path) both loop over the widget's values and apply str_replace( 'sanilwb_', '', $key ) to every key. So sanilwb_heading becomes heading as a shortcode attribute. The PHP handler reads it as $atts['heading'] after shortcode_atts().

Example: sanilwb_accent_color in the dialog → accent_color as a shortcode attribute → $atts['accent_color'] in PHP.

Icons use Lucide React components

Widget icons are Lucide React components (imported at the top of that widget's own file in config/widgets/), not string class names. Every built-in widget type already imports its own Lucide icon this way (see the full list and their icons in the Page Builder reference). Pick a Lucide icon that fits your widget and import it in your new file.


Step 1 — Create config/widgets/callout/callout.js and Register It

Create a new folder admin/assets/js/src/page-builder/config/widgets/callout/, then a new file inside it: admin/assets/js/src/page-builder/config/widgets/callout/callout.js.

First, add your widget's layer-bar colors to admin/assets/js/src/page-builder/config/widgets/widgetPalette.js — every built-in widget's barBg/barText pair lives there, keyed by an uppercase version of the type:

export const WIDGET_PALETTE = {
    POSTS:          { barBg: '#ede9fe', barText: '#5b21b6' },
    BUTTON:         { barBg: '#fce7f3', barText: '#9d174d' },
    // ...existing entries...
    CALLOUT:        { barBg: '#fef9c3', barText: '#713f12' },
};

Then, back in your new file, import your icon and the palette entry:

import { Megaphone } from 'lucide-react';
import { WIDGET_PALETTE } from './widgetPalette';

Export definition — the full WIDGET_TYPES[type] entry:

export const definition = {
    // The type string — must be unique across all widget types.
    // This is the value stored as 'widget_type' in the page layout JSON.
    type: 'callout',

    // Human-readable name shown in the Widget Picker dialog and on the layer bar.
    label: 'Callout',

    // Lucide React component, imported above.
    icon: Megaphone,

    // Layer-bar dot colors — from the WIDGET_PALETTE entry added above.
    barBg: WIDGET_PALETTE.CALLOUT.barBg,
    barText: WIDGET_PALETTE.CALLOUT.barText,

    // Initial field values applied when this widget type is first added to a column.
    // Keys must start with 'sanilwb_' — the PHP shortcode builder strips this prefix
    // (see "How field values flow from the dialog to the shortcode" above).
    // Add any field that should not be empty when the user first opens the dialog.
    defaultValues: {
        sanilwb_heading:       'Important Notice',
        sanilwb_body:          'Add your callout message here.',
        sanilwb_accent_color:  '#facc15',
    },
};

Notes on defaultValues:

  • Every field that should have a non-empty initial value needs an entry here.
  • Fields not listed here default to an empty string when a new widget is created.
  • The device visibility fields (sanilwb_show_desktop, etc.) and admin label (sanilwb_admin_label) are initialized automatically by the store — you do not need to add them here.

Now register the new file in admin/assets/js/src/page-builder/config/widgets/index.js. Add the import alongside the existing ones:

import * as callout from './callout/callout';

Add it to the WIDGET_TYPES object:

export const WIDGET_TYPES = {
    posts:           posts.definition,
    button:          button.definition,
    // ...existing entries...
    callout:         callout.definition,
};

Two more required registrations, easy to miss because nothing crashes right away without them. Every current widget type is on the JSON-driven Style tab system (see WIDGET-ARCHITECTURE.md), so Callout should be too, even though it doesn't redirect any field away from the default outer wrapper. This needs a small file plus one registration in each language:

1. Create admin/assets/js/src/page-builder/config/widgets/callout/callout.styles.json:

{
    "useSharedSections": [
        "Layout",
        "Size",
        "Typography",
        "Background",
        "Gradient",
        "Appearance",
        "Spacing",
        "Position"
    ],
    "ownFields": []
}

A bare string entry (like every one of these) keeps that section's default target — the widget's own outer wrapper. Callout doesn't need ownFields yet; Step 2 below adds its own Accent Color field here.

2. Register it in JSadmin/assets/js/src/page-builder/config/widgets/index.js: import the new file and add it to WIDGET_STYLES_JSON:

import calloutStyles from './callout/callout.styles.json';
const WIDGET_STYLES_JSON = {
    image:          imageStyles,
    // ...existing entries...
    callout:        calloutStyles,
};

ALLOWED_STYLE_TARGETS.callout and the widget's assembled Style tab fields are both derived from this automatically — nothing else in index.js needs to change.

3. Register it in PHPincludes/class-sanilwb-style-field-targets.php: add a matching entry to SANILWB_Style_Field_Targets::WIDGET_STYLES_JSON_FILENAMES:

'callout' => 'callout/callout.styles.json',

This PHP entry is not derived automatically from the widget type string, even for a single-word type like callout. A past bug (see WIDGET-ARCHITECTURE.md's filename-mapping note) tried deriving the filename directly from $widget_type, which broke silently for every multi-word widget type since their real files are camelCase. The fix removed that derivation entirely in favor of this explicit map — so skipping this step means get_widget_styles_config('callout') returns null in PHP, and Callout silently falls all the way back to the old bucket-based path instead. For a widget with no redirected fields (like Callout as built in this tutorial), that fallback happens to look identical, since an unlisted widget type's appearance bucket also defaults to 'root' — so this particular gap won't show up in Callout's own testing. It will surface the moment Callout (or the next widget you copy this tutorial for) redirects any field away from root, so add this entry now rather than relying on it "still working" today.

Unlike the JS side (tests/js/unit/shared/buildElementStyles.test.js fails loudly if WIDGET_TYPES and ALLOWED_STYLE_TARGETS fall out of sync — see below), there is currently no equivalent PHP test enforcing WIDGET_STYLES_JSON_FILENAMES covers every migrated widget type. Double-check this entry by hand.


Step 2 — Build the Dialog Tabs

Still in your new callout/callout.js file, export a buildTabs() function — every widget file exports one with this exact name (posts.js's buildTabs(), button.js's buildTabs(), etc.), so there's no naming collision to worry about even though they're all called the same thing — each lives in its own file. It returns the Content tab only — the Style tab is built entirely from callout/callout.styles.json (Step 1) instead, exactly the way every currently-migrated widget's own buildTabs() (e.g. heading.js) works today:

/**
 * Dialog tab definitions for the Callout widget.
 *
 * Returns the Content tab only — the Style tab (the full shared scheme plus
 * Callout's own Accent Color field) comes entirely from callout/callout.styles.json,
 * read by getWidgetDialogTabs() in index.js.
 */
export function buildTabs() {
    return [
        {
            id: 'content',
            label: 'Content',
            fields: [
                {
                    // Section header — a visual divider with a label. No stored value.
                    type: 'section',
                    label: 'Text',
                },
                {
                    // The bold heading at the top of the callout box.
                    name: 'sanilwb_heading',
                    type: 'text',
                    label: 'Heading',
                    responsive: false,
                },
                {
                    // The body paragraph text below the heading.
                    name: 'sanilwb_body',
                    type: 'text',
                    label: 'Body Text',
                    responsive: false,
                },
            ],
        },
    ];
}

No case 'callout': edit is needed in getWidgetDialogTabs()'s switch ( type ) statement for the Style tab itself — but you still need one so getWidgetDialogTabs() knows how to build the Content tab above:

case 'callout':
    tabs = callout.buildTabs();
    break;

Add Callout's own Accent Color field to callout/callout.styles.json's ownFields instead of buildTabs(). Update the file from Step 1:

{
    "useSharedSections": [
        "Layout",
        "Size",
        "Typography",
        "Background",
        "Gradient",
        "Appearance",
        "Spacing",
        "Position"
    ],
    "ownFields": [
        { "type": "section", "label": "Colors", "position": 15 },
        {
            "name": "sanilwb_accent_color",
            "type": "color",
            "label": "Accent Color",
            "responsive": false
        }
    ]
}

position: 15 places the "Colors" section (and everything under it, up to the next section marker) between Layout (position: 10) and Size (position: 20) — see admin/assets/js/src/shared/config/styleSectionPositions.json for the shared sections' own position numbers, and WIDGET-ARCHITECTURE.md's "Position-based section ordering" for the full mechanism. Accent Color has no target — it stays widget-local, read directly by your own render code in Steps 3 and 4, the same way it always was; it isn't part of the generic box-model/typography buckets, so it doesn't need one.

What getWidgetDialogTabs() appends automatically

After your buildTabs() returns, getWidgetDialogTabs() does two things:

  1. Finds the tab with id: 'style' and appends SHARED_STYLE_FIELDS into its fields array. SHARED_STYLE_FIELDS is the one shared Style tab scheme every widget and div uses — Layout (flex controls), Size (width/height/min/max/flex-grow/aspect-ratio), Typography (color, font, line-height, text-align, all with a Hover variant), Appearance (background + hover, box shadow, gradient, borders), Spacing (margin/padding), and Position (position, offsets, overflow). See WIDGET-ARCHITECTURE.md (admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source) for the full scheme and why it exists — do not redefine any of these fields in your own buildTabs().
  2. Appends the Advanced tab (buildSharedAdvancedTab(), admin/assets/js/src/shared/config/sharedDialogFields.js) as the last tab. This tab adds device visibility toggles (show/hide on desktop/tablet/mobile), the Admin Label field, the CSS Class field, and — if this widget's own .styles.json declares an attributeTargets array — a repeater for adding raw HTML attributes to its rendered tag(s). See Custom Attributes; optional, not required for a working widget.

You never need to add those manually. Your widget's own Style tab fields should only cover things genuinely specific to it — a data-driven layout choice (like Posts' Columns-per-Row), not a color/spacing/size/position concern the shared scheme already provides. Accent Color stays Callout-specific here because it's a one-off strip color, not one of the shared Typography/Appearance colors.

Where do these shared fields actually apply? By default, every shared field's declarations (Layout, Size, Typography, Appearance, Spacing, Position) land on the widget's own outer wrapper — the element render_widgets() (PHP) and the canvas both already create for every widget automatically, with zero code from you. Step 4 explains this wrapper and how the CSS gets there without you writing any style-building code in your shortcode class. If you instead need a field to apply to some other element inside your widget's own markup — see "Targeting a Different Inner Element" near the end of this tutorial.

Responsive vs. non-responsive fields

Setting responsive: true on a field means the editor stores three separate values:

Stored key Device
sanilwb_accent_color Desktop (base value)
sanilwb_accent_color__tablet Tablet override
sanilwb_accent_color__mobile Mobile override

The PHP shortcode handler receives all three as separate attributes. Use responsive: true for any property a designer might want to adjust per device (colors, sizes, spacing). Use responsive: false for content that doesn't change by device (heading text, a toggle option).


Step 3 — Add the JS Editor-Preview Renderer

This is the step the old version of this tutorial skipped. Callout's output depends only on its own field values (no live query, no compiled template, no third-party shortcode to expand), so per "How the editor canvas actually renders a widget" above, it should render in JS rather than fall back to an AJAX round trip.

Open admin/assets/js/src/page-builder/utils/renderWidgetPreview.js. Add a render function for Callout, modeled directly on the existing renderParagraphWidget()/renderHeadingWidget() functions already in that file:

function renderCalloutWidget( widget ) {
    const values = widget.values ?? {};

    // Callout doesn't redirect any Style-tab bucket away from the default
    // outer wrapper (see "Targeting a Different Inner Element" further down
    // for when a widget would need to) — CanvasWidgets.jsx already applies
    // the shared Layout/Size/Typography/Appearance/Spacing/Position styles
    // to that outer [data-layer-id] wrapper on its own, so this function only
    // needs to render Callout's own inner markup and its own local field
    // (accent_color).
    const heading = escHtml( values.sanilwb_heading ?? '' );
    const body = escHtml( values.sanilwb_body ?? '' );
    const accentColor = values.sanilwb_accent_color || '#e5e7eb';

    return `<div class="sanilwb-callout" style="border-left:4px solid ${ escAttr( accentColor ) };" data-uid="${ escAttr( widget.uid ) }">` +
        ( heading ? `<h4 class="sanilwb-callout__heading">${ heading }</h4>` : '' ) +
        ( body ? `<p class="sanilwb-callout__body">${ body }</p>` : '' ) +
        `</div>`;
}

Register it in the dispatcher's switch statement, in renderWidgetPreview():

case 'callout':
    return renderCalloutWidget( widget );

And add 'callout' to the JS_RENDERED_WIDGET_TYPES set near the bottom of the same file:

const JS_RENDERED_WIDGET_TYPES = new Set( [
    'heading',
    'paragraph',
    'image',
    'button',
    'post_title',
    'archive_title',
    'featured_image',
    'excerpt',
    'term_name',
    'callout',
] );

This second registration matters beyond just the switch case — CanvasWidgets.jsx uses isJsRenderedWidgetType() to decide whether to show its AJAX-oriented loading spinner/debounce. Leaving Callout out of this set would make every keystroke show a loading flicker meant for the real server round trip, even though renderWidgetPreview() already resolved it locally.

Escaping rule: escHtml() HTML-escapes plain text before it goes inside an element (used for sanilwb_heading/sanilwb_body above, since those are plain text fields). escAttr() escapes a value going inside an HTML attribute (used for the accent color and the data-uid). Never interpolate a field value into the returned HTML string without one of these two functions — this is the same rule the PHP side enforces with esc_html()/esc_attr() in Step 4.

If a future field on your widget holds real HTML (like Paragraph's WYSIWYG sanilwb_text field), it must not be run through escHtml() — see renderParagraphWidget() in the same file for why, and its docblock comment about <p>-inside-<p> nesting.


Step 4 — Create the PHP Shortcode Class

Each widget type renders through its own class in includes/shortcodes/, one render() method per file — mirroring the one-subfolder-per-widget-type split config/widgets/ already uses on the JS side. Create a new file includes/shortcodes/class-sanilwb-callout-shortcode.php:

<?php
if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * Renders the [sanilwb type="callout"] shortcode.
 *
 * @package    Sanil_Website_Builder
 * @subpackage Sanil_Website_Builder/includes/shortcodes
 */
class SANILWB_Callout_Shortcode {

    public static function render( $sanitized_attributes ) {

        // Sanitize all incoming attribute values before using them in HTML.
        // Keys have the 'sanilwb_' prefix already stripped in PHP (see "How field
        // values flow from the dialog to the shortcode" above) before this render()
        // method ever sees them.
        $args = shortcode_atts( array(
            'heading'      => 'Important Notice',
            'body'         => '',
            'accent_color' => '',
        ), $sanitized_attributes );

        // sanitize_hex_color() returns an empty string if the value is not a valid hex color.
        // Always fall back to a safe default so the style attribute is never left with
        // a raw user-supplied value like "javascript:..." or a partial hex like "#f".
        $accent_color = sanitize_hex_color( $args['accent_color'] );

        // Build the inline border-left style using the sanitized color.
        // If no valid color was saved, fall back to a neutral grey.
        $border_color = $accent_color ?: '#e5e7eb';
        $border_style = 'border-left: 4px solid ' . $border_color . ';';

        // Render the HTML. ob_start() / ob_get_clean() lets you write plain HTML
        // without messy string concatenation.
        ob_start();
        ?>
        <div class="sanilwb-callout" style="<?php echo esc_attr( $border_style ); ?>">

            <?php if ( $args['heading'] ) : ?>
                <h4 class="sanilwb-callout__heading">
                    <?php echo esc_html( $args['heading'] ); ?>
                </h4>
            <?php endif; ?>

            <?php if ( $args['body'] ) : ?>
                <p class="sanilwb-callout__body">
                    <?php echo esc_html( $args['body'] ); ?>
                </p>
            <?php endif; ?>

        </div>
        <?php
        return ob_get_clean();
    }

}

This looks like the entire widget, but it is only the inner markup. Here is exactly what wraps around it once the page actually renders, so you know what to expect and don't try to re-build it yourself:

<div class="sanilwb-widget-row {visibility classes} sanilwb-widget-{uid}">
  <div class="sanilwb-widget">
    <!-- your shortcode class's render() output goes here -->
    <div class="sanilwb-callout" style="border-left: 4px solid #facc15;">
      <h4 class="sanilwb-callout__heading">Important Notice</h4>
      <p class="sanilwb-callout__body">Add your callout message here.</p>
    </div>
  </div>
</div>

SANILWB_Frontend_Renderer::render_widgets() (includes/class-sanilwb-frontend-renderer.php) builds the outer two divs for every widget type, unconditionally, before ever calling your shortcode's render() method. Two things happen there that you never need to do yourself:

  • The .sanilwb-widget-row.sanilwb-widget-{uid} div gets the widget's visibility classes (device show/hide) and its scoped sanilwb-widget-{widget_uid} class.
  • collect_element_css() runs against that same outer div, reading whichever Style-tab fields resolve to the default root target — which is everything from SHARED_STYLE_FIELDS unless you explicitly redirected a bucket (see "Targeting a Different Inner Element"). This is how Layout, Size, Typography, Appearance, Spacing, and Position all end up correctly styling the callout box's own margin/padding/border/background/etc. without a single line of style-building code in SANILWB_Callout_Shortcode.

Your shortcode class's only real job is the widget's own semantic inner markup (the heading tag, the body tag, the accent border) and any field that is genuinely local to this widget and not part of the shared scheme — accent_color here.

Then open includes/class-sanilwb-shortcode-handler.php and add one elseif branch to SANILWB_Shortcode_Handler::process_shortcodes(), alongside the existing branches for every other widget type:

} elseif ( 'callout' === $sanitized_attributes['type'] ) {
    return SANILWB_Callout_Shortcode::render( $sanitized_attributes );
}

Sanitization rules to follow for every widget:

Input type Sanitization function
Plain text / labels sanitize_text_field() — strips tags and extra whitespace
Hex colors sanitize_hex_color() — returns empty string if invalid
URLs esc_url() — rejects non-URL characters
HTML output esc_html() — escapes <, >, &, "
Attribute output esc_attr() — same escaping, safe inside HTML attributes

Never echo a raw attribute value directly into HTML. Every value from user input is untrusted.

Need a helper used by other widgets (placeholders, cache keys, typography attrs)? Check includes/shortcodes/class-sanilwb-shortcode-helpers.php first — it holds the cross-widget utilities (dynamic_widget_placeholder(), get_query_object(), TYPOGRAPHY_ATTR_DEFAULTS, etc.) so widget classes don't duplicate them. Call them as SANILWB_Shortcode_Helpers::method_name(...).


Step 5 — Build the JavaScript

Run the build from the plugin root:

# One-time production build (use before committing or deploying)
npm run build

# Or use watch mode if you are actively making changes
npm run start

Checklist Before Testing

  • [ ] WIDGET_PALETTE.CALLOUT added to widgetPalette.js
  • [ ] config/widgets/callout/callout.js created, exporting definition (type, label, icon, barBg, barText, defaultValues) and buildTabs() (returning one tab, content — no style tab)
  • [ ] config/widgets/callout/callout.styles.json created (useSharedSections with every shared section, ownFields with the Accent Color field)
  • [ ] import * as callout from './callout/callout'; added to config/widgets/index.js, callout.definition added to WIDGET_TYPES, and case 'callout': added to getWidgetDialogTabs()'s switch block, calling callout.buildTabs()
  • [ ] import calloutStyles from './callout/callout.styles.json'; added to config/widgets/index.js, and callout: calloutStyles added to WIDGET_STYLES_JSON
  • [ ] 'callout' => 'callout/callout.styles.json' added to SANILWB_Style_Field_Targets::WIDGET_STYLES_JSON_FILENAMES (includes/class-sanilwb-style-field-targets.php) — the PHP side of the same registration; no test currently catches a missed entry here, so double-check it by hand
  • [ ] renderCalloutWidget() added to renderWidgetPreview.js, with a case 'callout': in renderWidgetPreview()'s switch, and 'callout' added to JS_RENDERED_WIDGET_TYPES
  • [ ] includes/shortcodes/class-sanilwb-callout-shortcode.php created with a SANILWB_Callout_Shortcode::render() method
  • [ ] elseif ( 'callout' === ... ) branch added to SANILWB_Shortcode_Handler::process_shortcodes() in includes/class-sanilwb-shortcode-handler.php
  • [ ] If definition declares contexts narrower than "everywhere" — a matching entry added to SANILWB_Ajax::WIDGET_CONTEXTS (includes/class-sanilwb-ajax.php); otherwise saving this widget inside a Template or Site Layout fails with a 422 ("This layout contains a widget or feature that is not allowed here"), even though a plain Page save and the Widget Picker UI both work fine — see "Restricting which builder contexts allow this widget" above
  • [ ] npm run build completed without errors
  • [ ] npm run test:js passes — catches a missed callout/callout.styles.json JS-side registration immediately (WIDGET_TYPES/ALLOWED_STYLE_TARGETS coverage test in buildElementStyles.test.js) instead of at some later, harder-to-trace point

Testing It

  1. Open any page in the Page Builder.
  2. Add a div to the canvas. Hover it and click the + button (or use its layer bar's menu) → Add Widget.
  3. You should see Callout in the Widget Picker with a yellow bar and the Megaphone icon.
  4. Click it. The settings dialog opens immediately with a Content tab (heading, body text) and a Style tab (accent color, then the shared spacing/border controls).
  5. Type a heading and body text. Set a bright accent color. Click Save.
  6. The canvas updates instantly, with no loading spinner — that's renderWidgetPreview()'s JS render path from Step 3, not an AJAX round trip. You should see the callout box with the left border in the chosen color.
  7. Open the widget settings again and go to the Advanced tab. Set the Admin Label to "Promo Callout". Close and confirm the layer bar now shows "Promo Callout" instead of "Callout 1".
  8. Publish or update the page and check the frontend — the callout box should appear exactly as configured. This exercises Step 4's PHP path, completely independent of Step 3's JS path — if the two ever disagree, this is where you'd see it.

Troubleshooting

The widget doesn't appear in the Widget Picker

Check for a JavaScript syntax error in your new callout/callout.js file or in config/widgets/index.js — open the browser DevTools console on the admin page and look for errors. Also confirm you ran npm run build after editing the files.

The settings dialog has a Content tab but no Style tab (or the Style tab is missing your Accent Color field)

Check that config/widgets/index.js has both required registrations from Step 1: callout: calloutStyles in WIDGET_STYLES_JSON, and the matching import calloutStyles from './callout/callout.styles.json';. getWidgetDialogTabs() builds the Style tab entirely from WIDGET_STYLES_JSON[type] when present — a missing registration there means Callout gets no Style tab at all, regardless of what buildTabs() (in callout/callout.js) returns. If the Style tab appears but Accent Color is missing, check callout/callout.styles.json's ownFields array directly for a typo in the field's name or a missing position on its section marker.

The canvas shows a blank space (or a loading spinner that never resolves) after adding the widget

If you completed Step 3: check for a JS error in renderCalloutWidget() — a thrown error there surfaces as a blank canvas widget, not a visible error message. Confirm 'callout' was added to both the switch in renderWidgetPreview() and the JS_RENDERED_WIDGET_TYPES set — if only the switch case was added but not the set, the widget still renders correctly but shows the AJAX loading spinner needlessly on every change.

If you skipped Step 3 (AJAX fallback path only): your shortcode class may have a PHP error. Check the WordPress debug log (wp-content/debug.log if WP_DEBUG_LOG is enabled). Also confirm the elseif ( 'callout' === $sanitized_attributes['type'] ) string in SANILWB_Shortcode_Handler::process_shortcodes() matches your widget's type exactly, and that the class name in that branch (SANILWB_Callout_Shortcode) matches the class you actually defined.

A field value is not reaching the PHP handler

Confirm the field name in buildTabs() (in callout/callout.js) starts with sanilwb_ (e.g. sanilwb_heading). Fields without this prefix are excluded from the shortcode. Also confirm your render() method reads the attribute without the prefix (e.g. $args['heading'] after shortcode_atts(), not $args['sanilwb_heading']).

The editor preview and the real frontend look different

Since Step 3 (JS) and Step 4 (PHP) are two independent implementations of the same widget, nothing keeps them in sync automatically except you writing matching logic in both. Compare renderCalloutWidget()'s output against SANILWB_Callout_Shortcode::render()'s output field-by-field. This is the tradeoff of the instant-preview JS path — see WIDGET-ARCHITECTURE.md's "Markup Generation" section for why this split exists anyway (a full AJAX round trip on every keystroke would make the canvas feel sluggish for every simple widget).

The responsive tablet/mobile values aren't applying on the PHP/frontend side for a widget-local field

If a widget-local field (like sanilwb_accent_color if you made it responsive: true) is responsive, the JS sends three attributes: accent_color, accent_color__tablet, accent_color__mobile. Your PHP handler needs to read all three and apply the appropriate one based on the device. Since the shortcode renders server-side (not per-device), you typically emit all three as CSS custom properties with media queries, then reference the custom property in your widget's inline style or stylesheet. (Shared Style-tab fields don't need this — collect_element_css() already handles their responsive breakpoints for you, as explained in Step 4.)


Complete Code Reference

Everything above is spread across five steps, mixed with explanation. Below is the full, assembled contents of every file this tutorial creates or edits — nothing invented, nothing omitted, no truncation with ... — so you can diff your own work against a known-good version.

New file: admin/assets/js/src/page-builder/config/widgets/callout/callout.js

import { Megaphone } from 'lucide-react';
import { WIDGET_PALETTE } from './widgetPalette';

export const definition = {
    type: 'callout',
    label: 'Callout',
    icon: Megaphone,
    barBg: WIDGET_PALETTE.CALLOUT.barBg,
    barText: WIDGET_PALETTE.CALLOUT.barText,
    defaultValues: {
        sanilwb_heading:       'Important Notice',
        sanilwb_body:          'Add your callout message here.',
        sanilwb_accent_color:  '#facc15',
    },
};

export function buildTabs() {
    return [
        {
            id: 'content',
            label: 'Content',
            fields: [
                {
                    type: 'section',
                    label: 'Text',
                },
                {
                    name: 'sanilwb_heading',
                    type: 'text',
                    label: 'Heading',
                    responsive: false,
                },
                {
                    name: 'sanilwb_body',
                    type: 'text',
                    label: 'Body Text',
                    responsive: false,
                },
            ],
        },
    ];
}

New file: admin/assets/js/src/page-builder/config/widgets/callout/callout.styles.json

{
    "useSharedSections": [
        "Layout",
        "Size",
        "Typography",
        "Background",
        "Gradient",
        "Appearance",
        "Spacing",
        "Position"
    ],
    "ownFields": [
        { "type": "section", "label": "Colors", "position": 15 },
        {
            "name": "sanilwb_accent_color",
            "type": "color",
            "label": "Accent Color",
            "responsive": false
        }
    ]
}

Edits to existing file: admin/assets/js/src/page-builder/config/widgets/widgetPalette.js

Add one line inside the existing WIDGET_PALETTE object:

CALLOUT: { barBg: '#fef9c3', barText: '#713f12' },

Edits to existing file: admin/assets/js/src/page-builder/config/widgets/index.js

Five additions, none of which replace anything already there:

// Near the top, alongside the other widget imports:
import * as callout from './callout/callout';
// Near the top, alongside the other .styles.json imports:
import calloutStyles from './callout/callout.styles.json';
// Inside the WIDGET_TYPES object:
callout: callout.definition,
// Inside the WIDGET_STYLES_JSON object:
callout: calloutStyles,
// Inside getWidgetDialogTabs()'s switch ( type ) statement:
case 'callout':
    tabs = callout.buildTabs();
    break;

Edits to existing file: includes/class-sanilwb-style-field-targets.php

One new entry in SANILWB_Style_Field_Targets::WIDGET_STYLES_JSON_FILENAMES — the PHP-side registration matching WIDGET_STYLES_JSON above (see Step 1 for why this is required even for a single-word type like callout):

'callout' => 'callout/callout.styles.json',

Edits to existing file: admin/assets/js/src/page-builder/utils/renderWidgetPreview.js

One new function, placed alongside the other render*Widget() functions:

function renderCalloutWidget( widget ) {
    const values = widget.values ?? {};

    const heading = escHtml( values.sanilwb_heading ?? '' );
    const body = escHtml( values.sanilwb_body ?? '' );
    const accentColor = values.sanilwb_accent_color || '#e5e7eb';

    return `<div class="sanilwb-callout" style="border-left:4px solid ${ escAttr( accentColor ) };" data-uid="${ escAttr( widget.uid ) }">` +
        ( heading ? `<h4 class="sanilwb-callout__heading">${ heading }</h4>` : '' ) +
        ( body ? `<p class="sanilwb-callout__body">${ body }</p>` : '' ) +
        `</div>`;
}

One new case inside renderWidgetPreview()'s switch ( widget.type ):

case 'callout':
    return renderCalloutWidget( widget );

One new entry in the JS_RENDERED_WIDGET_TYPES set:

const JS_RENDERED_WIDGET_TYPES = new Set( [
    'heading',
    'paragraph',
    'image',
    'button',
    'post_title',
    'archive_title',
    'featured_image',
    'excerpt',
    'term_name',
    'callout',
] );

New file: includes/shortcodes/class-sanilwb-callout-shortcode.php

<?php
if ( ! defined( 'ABSPATH' ) ) exit;

/**
 * Renders the [sanilwb type="callout"] shortcode.
 *
 * @package    Sanil_Website_Builder
 * @subpackage Sanil_Website_Builder/includes/shortcodes
 */
class SANILWB_Callout_Shortcode {

    public static function render( $sanitized_attributes ) {

        $args = shortcode_atts( array(
            'heading'      => 'Important Notice',
            'body'         => '',
            'accent_color' => '',
        ), $sanitized_attributes );

        $accent_color = sanitize_hex_color( $args['accent_color'] );
        $border_color = $accent_color ?: '#e5e7eb';
        $border_style = 'border-left: 4px solid ' . $border_color . ';';

        ob_start();
        ?>
        <div class="sanilwb-callout" style="<?php echo esc_attr( $border_style ); ?>">

            <?php if ( $args['heading'] ) : ?>
                <h4 class="sanilwb-callout__heading">
                    <?php echo esc_html( $args['heading'] ); ?>
                </h4>
            <?php endif; ?>

            <?php if ( $args['body'] ) : ?>
                <p class="sanilwb-callout__body">
                    <?php echo esc_html( $args['body'] ); ?>
                </p>
            <?php endif; ?>

        </div>
        <?php
        return ob_get_clean();
    }

}

Edits to existing file: includes/class-sanilwb-shortcode-handler.php

One new elseif branch inside SANILWB_Shortcode_Handler::process_shortcodes():

} elseif ( 'callout' === $sanitized_attributes['type'] ) {
    return SANILWB_Callout_Shortcode::render( $sanitized_attributes );
}

That's the complete, working set — three new files (callout/callout.js, callout/callout.styles.json, the shortcode class), seven small edits to existing files. Nothing else needs to change; includes/shortcodes/ is auto-loaded by glob(), and the store/dialog/canvas machinery is already widget-type-agnostic.


Targeting a Different Inner Element

Everything above assumes Callout's shared Style-tab fields (Layout, Size, Typography, Appearance, Spacing, Position) should all apply to the widget's own outer wrapper — the default, and the right choice for most widgets, including Callout as built in this tutorial.

Some widgets need a Style field to apply to a different element inside their own markup instead — not the outer wrapper. Image/Featured Image (their Size fields target the element wrapping the <img>) and Button (its whole shared scheme targets its own tag) are two real, existing examples.

If Callout needs this too — e.g. a Background Color that should apply to .sanilwb-callout__body instead of the outer wrapper — see the dedicated follow-on tutorial: Targeting a Different Inner Element. It covers both the lightweight option (a new widget-local field, no shared config touched) and the full option (redirecting a shared section to a genuinely new target) — both resting-state CSS and hover are fully automatic on the PHP side for either option; only JS still needs a small manual call in your widget's own render function (see that tutorial's Path B, step 3).


Changing an Existing Widget's Default Styles

You don't need to touch the PHP shortcode or the JS preview renderer just to change what values a widget starts with — only defaultValues in that widget's own config file.

Example: the Button widget (admin/assets/js/src/page-builder/config/widgets/button/button.js) ships with these defaults:

defaultValues: {
    sanilwb_label: 'Read More',
    sanilwb_url: '',
    sanilwb_bck_color: 'var(--color-primary)',
    sanilwb_color: 'var(--color-background)',
    sanilwb_font_weight: '600',
    sanilwb_layout_type: 'inline-block',
    sanilwb_padding_top: '6px',
    sanilwb_padding_right: '15px',
    sanilwb_padding_bottom: '6px',
    sanilwb_padding_left: '15px',
    sanilwb_border_radius_top_left: '5px',
    sanilwb_border_radius_top_right: '5px',
    sanilwb_border_radius_bottom_right: '5px',
    sanilwb_border_radius_bottom_left: '5px',
    ...
},

sanilwb_bck_color and sanilwb_layout_type are the same shared Appearance/Layout fields every other widget uses (see WIDGET-ARCHITECTURE.md, admin/assets/js/src/page-builder/WIDGET-ARCHITECTURE.md in the plugin source) — Button doesn't define its own Style tab fields at all anymore, only a default value for fields the shared scheme already provides.

Two things worth knowing before you edit values like these:

Use a Global Color reference, not a hardcoded hex, whenever the value should follow the theme. 'var(--color-primary)' and 'var(--color-background)' are not arbitrary strings — they are the exact CSS custom property names Theme Options writes to :root for each configured color swatch (see admin/class-sanilwb-admin-theme-options.php). Both SANILWB_CSS_Compiler::sanitize_color_or_var() (PHP) and sanitizeColorOrVar() (shared/utils/colorValue.js) explicitly allow-list the var(--color-*) pattern, so a default set this way resolves to whatever color the site owner has configured, and updates automatically if they change it later. A default like '#2271b1' is just a fixed hex value — it never reacts to Theme Options at all. If you want a widget to default to a specific Theme Options color, look up that color's key in Theme Options (e.g. primary, background, text) and use var(--color-{key}).

Padding/margin/border-radius/border-width defaults must carry their unit explicitly, e.g. '6px', not a bare '6'. resolve_px_value() (PHP) and toCssValue()/parseUnitValue() (JS) both treat a bare numeric string with no unit as incomplete input and skip emitting that CSS property entirely — this project has no backward-compatibility requirement to guess a unit for it (see root CLAUDE.md, and toCssValue()'s own docblock in shared/utils/buildAppearanceStyle.js). A defaultValues entry for any unit-carrying field (padding, margin, border-radius, border-width, width, height, and any custom field using the cssProperty escape hatch — see WIDGET-ARCHITECTURE.md) needs its unit written out, exactly as InputGroupField/NumberField would store it the moment a user actually picks a value.

No PHP or JS renderer change is needed for either kind of default — only the widget's own defaultValues object. This only affects newly created widget instances; any Button already placed on a page — or on a template, since those are Page Builder documents too (see Page Builder) — keeps whatever values were saved when it was added.