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:
- Finds the tab with
id: 'style'and appendsSHARED_STYLE_FIELDSinto itsfieldsarray.SHARED_STYLE_FIELDSadds the Container section (margin/padding) and the Borders section (radius, width, style, color). - Appends
SHARED_ADVANCED_TABas 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 ofwidgetTypes.js - [ ]
calloutentry added toWIDGET_TYPESwithtype,label,icon,barBg,barText, anddefaultValues - [ ]
case 'callout':added togetWidgetDialogTabs()switch block, callingbuildCalloutTabs() - [ ]
buildCalloutTabs()function defined, returning two tabs (contentandstyle) - [ ] Shortcode case for
'callout'added topublic/class-sanil-website-builder-public.php - [ ]
npm run buildcompleted without errors
Testing It
- Open any page in the Page Builder.
- Add a column to a row. Click the column bar → Add Widget.
- You should see Callout in the Widget Picker with a yellow bar and the Megaphone icon.
- 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).
- Type a heading and body text. Set a bright accent color. Click Save.
- The canvas refreshes. You should see the callout box with the left border in the chosen color.
- 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".
- 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.