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 correct, sanitized HTML on the frontend
  • show a correctly labeled bar in the Layers Panel

Follow every step in order.


Before You Start

The two-file system

Unlike Template Builder node types (which span three or four files), a Page Builder widget type lives in exactly two places:

File What it does
admin/assets/js/src/page-builder/config/widgetTypes.js Declares the widget, its icon, its default values, and its settings dialog tabs
public/class-sanil-website-builder-public.php Renders the widget's HTML on the frontend via the [sanilwb] shortcode

There is no separate JS canvas renderer for Page Builder widgets. The canvas loads a real WordPress page in an iframe and renders widgets through the same shortcode system visitors see — so the canvas preview is always identical to the live site.

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 shortcode is built (buildShortcode()), the sanilwb_ prefix is stripped from each key name. So sanilwb_heading becomes heading as a shortcode attribute. The PHP handler reads it as $atts['heading'].

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 widgetTypes.js), 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 alongside the others.


Step 1 — Register the Widget Type in widgetTypes.js

Open admin/assets/js/src/page-builder/config/widgetTypes.js.

At the top of the file, find the existing Lucide icon imports and add your icon:

import { Pin, RectangleHorizontal, Type, Megaphone } from 'lucide-react';
//                                               ↑ add your new icon here

Then find the WIDGET_TYPES object. Add your new entry at the bottom, inside the closing };:

callout: {
    // 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 at the top of the file.
    icon: Megaphone,

    // Background color for the colored dot on the widget layer bar.
    barBg: '#fef9c3',

    // Text color for the layer bar dot — must have sufficient contrast against barBg.
    barText: '#713f12',

    // Initial field values applied when this widget type is first added to a column.
    // Keys must start with 'sanilwb_' — buildShortcode() strips this prefix.
    // 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.

Step 2 — Build the Dialog Tabs

Still in widgetTypes.js, find the getWidgetDialogTabs(type, availableTemplates, categories) function. It contains a switch (type) statement. Add a new case at the bottom, before the default: case:

case 'callout': {
    tabs = buildCalloutTabs();
    break;
}

Then define buildCalloutTabs() as a new function in the file. Place it below the existing builder functions (buildPostsTabs, buildReadMoreBtnTabs, buildTitleBarTabs) so the file stays organized:

/**
 * Dialog tab definitions for the Callout widget.
 *
 * Returns the Content and Style tabs.
 * getWidgetDialogTabs() automatically appends SHARED_STYLE_FIELDS into
 * the Style tab and adds SHARED_ADVANCED_TAB at the end — do not add
 * those manually here.
 */
function buildCalloutTabs() {

    // The Content tab holds all user-facing text.
    const contentTab = {
        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,
            },
        ],
    };

    // The Style tab holds visual options for the callout.
    // SHARED_STYLE_FIELDS (margin, padding, borders) will be appended into
    // this tab automatically by getWidgetDialogTabs() — do not add them here.
    const styleTab = {
        id: 'style',
        label: 'Style',
        fields: [
            {
                // showDeviceToggle: true renders the desktop/tablet/mobile
                // toggle buttons next to this section header.
                type: 'section',
                label: 'Colors',
                showDeviceToggle: false,
            },
            {
                // The left border / accent strip color.
                // responsive: false — this color stays the same on all devices.
                name: 'sanilwb_accent_color',
                type: 'color',
                label: 'Accent Color',
                responsive: false,
            },
        ],
    };

    return [ contentTab, styleTab ];
}

What getWidgetDialogTabs() appends automatically

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

  1. Finds the tab with id: 'style' and appends SHARED_STYLE_FIELDS into its fields array. SHARED_STYLE_FIELDS adds the Container section (margin/padding) and the Borders section (radius, width, style, color).
  2. Appends SHARED_ADVANCED_TAB as the last tab. This tab adds device visibility toggles (show/hide on desktop/tablet/mobile), the Admin Label field, and the CSS Class field.

You never need to add those manually.

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 PHP Shortcode Handler

Open public/class-sanil-website-builder-public.php.

Find the main shortcode handler function. It reads $atts['type'] and dispatches based on the widget type. Add a case for your new widget:

case 'callout': {
    // Sanitize all incoming attribute values before using them in HTML.
    // $atts keys have the 'sanilwb_' prefix already stripped by buildShortcode() in JS.
    $heading      = sanitize_text_field( $atts['heading']      ?? 'Important Notice' );
    $body         = sanitize_text_field( $atts['body']         ?? '' );

    // 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( $atts['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 ( $heading ) : ?>
            <h4 class="sanilwb-callout__heading">
                <?php echo esc_html( $heading ); ?>
            </h4>
        <?php endif; ?>

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

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

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 $atts value directly into HTML. Every value from user input is untrusted.


Step 4 — 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

  • [ ] Megaphone (or your chosen icon) imported from 'lucide-react' at the top of widgetTypes.js
  • [ ] callout entry added to WIDGET_TYPES with type, label, icon, barBg, barText, and defaultValues
  • [ ] case 'callout': added to getWidgetDialogTabs() switch block, calling buildCalloutTabs()
  • [ ] buildCalloutTabs() function defined, returning two tabs (content and style)
  • [ ] Shortcode case for 'callout' added to public/class-sanil-website-builder-public.php
  • [ ] npm run build completed without errors

Testing It

  1. Open any page in the Page Builder.
  2. Add a column to a row. Click the column bar → 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 refreshes. 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.

Troubleshooting

The widget doesn't appear in the Widget Picker

Check for a JavaScript syntax error in widgetTypes.js — open the browser DevTools console on the admin page and look for errors. Also confirm you ran npm run build after editing the file.

The settings dialog is empty

Check that getWidgetDialogTabs() has a case 'callout': and that buildCalloutTabs() returns an array with at least two items (a content tab and a style tab). A missing id: 'style' on the style tab causes SHARED_STYLE_FIELDS to be silently skipped.

The canvas shows a blank space after adding the widget

The shortcode handler may have a PHP error. Check the WordPress debug log (wp-content/debug.log if WP_DEBUG_LOG is enabled). Also confirm the case string in the PHP switch matches 'callout' exactly.

A field value is not reaching the PHP handler

Confirm the field name in buildCalloutTabs() starts with sanilwb_ (e.g. sanilwb_heading). Fields without this prefix are excluded from the shortcode. Also confirm the PHP handler reads the attribute without the prefix (e.g. $atts['heading'], not $atts['sanilwb_heading']).

The responsive tablet/mobile values aren't applying on the frontend

If a field is responsive: true, 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.