Add a shortcode (page-builder element)
Page-builder elements are shortcodes. To add one you create a folder under the Shortcodes extension and the framework auto-discovers it, no core edits. There are two kinds:
- Simple content elements (a Button, Divider, Text Block, …) — the common case. A folder with a config, options, view, and assets. No PHP class required.
- Section-like elements (a custom Section variant that lives at the page root and holds rows) — needs a small registration class. Covered at the end.
Simple element: folder structure
Create framework/extensions/shortcodes/shortcodes/<your_element>/:
shortcodes/my_element/
├── config.php page-builder tab / title / icon / popup size
├── options.php the fields shown in the edit modal (= the atts schema)
├── views/view.php the front-end HTML template
├── static.php front-end CSS/JS enqueues (optional)
└── static/
├── img/page_builder.svg the palette icon (REQUIRED)
└── css/my_element.css (optional) front-end styles
That's it. The Shortcodes loader scans this directory; a folder with a config.php and a
views/view.php becomes a registered element automatically. The shortcode tag is the folder name
(use underscores: my_element).
config.php — where it appears in the builder
<?php if ( ! defined( 'FW' ) ) die( 'Forbidden' );
$cfg = array();
$cfg['page_builder'] = array(
'title' => __( 'My Element', 'fw' ),
'description' => __( 'What this element does', 'fw' ),
'tab' => __( 'Content Elements', 'fw' ), // which palette tab
'popup_size' => 'small', // edit-modal width: small | medium | large
);
The tab decides which palette group it lands in (Content / Media / Interactive / Components /
Header-Footer Elements).
options.php — the fields (and the atts schema)
options.php returns an array of option types. Each becomes a field in
the element's edit modal, and each field's saved value becomes a key in the element's atts.
<?php if ( ! defined( 'FW' ) ) die( 'Forbidden' );
$options = array(
'heading' => array(
'type' => 'text',
'label' => __( 'Heading', 'fw' ),
'value' => __( 'Hello', 'fw' ),
),
'align' => array(
'type' => 'select',
'label' => __( 'Alignment', 'fw' ),
'choices' => array( 'left' => 'Left', 'center' => 'Center', 'right' => 'Right' ),
'value' => 'left',
),
);
options.php is a contractThe shape of options.php is the schema that the page-builder JSON (template export/import,
demo importers, AI generators) must match when producing atts for this element. Keep it documented
in the element's sibling AGENTS.md.
views/view.php — the front-end output
$atts (the resolved option values) and $content (rendered inner content, for container elements)
are in scope. Output lean, semantic markup, no wrapper-div soup, per the
clean-DOM philosophy:
<?php if ( ! defined( 'FW' ) ) die( 'Forbidden' );
/** @var array $atts */
?>
<div class="my-element my-element--<?php echo esc_attr( $atts['align'] ); ?>">
<h3><?php echo esc_html( $atts['heading'] ); ?></h3>
</div>
static.php — enqueue assets (optional)
<?php if ( ! defined( 'FW' ) ) die( 'Forbidden' );
wp_enqueue_style(
'my-element',
fw_ext( 'shortcodes' )->get_uri( '/shortcodes/my_element/static/css/my_element.css' ),
array(),
fw_ext( 'shortcodes' )->manifest->get_version() // cache-bust on version bump
);
The builder icon (required)
Always ship static/img/page_builder.svg. The builder auto-detects it (SVG preferred) and inlines it
as the palette icon. House style for simple content / header-footer elements is a crisp 16×16
monochrome glyph, displayed 1:1:
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="#b3b3b3" shape-rendering="crispEdges" aria-hidden="true">
<!-- pixel-aligned rects / paths for the element's purpose -->
</svg>
Use viewBox="0 0 16 16" (16-grid = 1:1 crisp pixels at the 16px render size), a fixed
fill="#b3b3b3" (not currentColor, which resolves too dark in the editor), crispEdges, and
integer coordinates. Match the density of an existing icon (text-block, accordion, site-search
are good references). Layout / section-like elements use a larger thumbnail style instead
(viewBox="0 0 60 40", outlined strokes) — see section/.
Verify
After uploading and a hard refresh on a post-edit screen:
- Your element shows in its palette tab with the configured title + icon.
- Drag it onto the canvas; the edit modal opens with your options.
- Set values, save the page, view the front end;
view.phprenders with youratts.
Section-like elements
A section-like element (a custom Section variant that sits at the page root and holds rows/columns)
needs a little registration so the items-corrector, section-sorter, and Save-as-Template all treat it
like the built-in [section]. You add one shortcode class and one page-builder item class,
wired through three hooks in the shortcode's _init():
class FW_Shortcode_My_Section extends FW_Shortcode {
public function _init() {
// 1. Load the page-builder item class when the editor renders.
add_action( 'fw_option_type_builder:page-builder:register_items',
array( $this, '_action_register_builder_item_types' ) );
// 2. Eagerly mark this type "section-like" for admin-ajax / the items
// corrector (CRITICAL — without it, template save / import mis-handle it).
add_filter( 'fw_section_like_types', function ( $types ) {
$types[] = 'my_section';
return $types;
} );
// 3. Expose this element's data bundle to the editor collector.
add_filter( 'fw_ext:shortcodes:collect_shortcodes_data', array( $this, '_filter_add_data' ) );
}
}
The item class just extends the shared base and declares its type:
class Page_Builder_My_Section_Item extends Page_Builder_Section_Like_Item {
public function get_type() { return 'my_section'; }
}
FW_Option_Type_Builder::register_item_type( 'Page_Builder_My_Section_Item' );
Extending Page_Builder_Section_Like_Item gives you the registry registration, items-corrector
opt-outs, _items recursion, the Save-as-Template UI, export/import round-trip, and the hierarchy
guards for free.
Don't forget the eager fw_section_like_types filter (step 2). The register_items action only
fires on the editor-render lifecycle; admin-ajax handlers (template save, import, the items corrector
running on wp_insert_post) won't see your type as section-like without the filter, and will reject
or mis-handle it.
The canonical worked example lives in the plugin at
framework/extensions/shortcodes/shortcodes/AGENTS.md (the section-like recipe) with hero-section/
as the reference implementation.