Advanced Elementor Architecture: Custom Controls, Skins, and ACF Data Integration
Transcend standard widget creation by architecting a fully integrated Elementor ecosystem. This comprehensive engineering guide explores deep extensibility—from defining custom `Base_Control` classes and polymorphic Widget Skins to engineering proprietary Dynamic Tags and Form Actions. We further bridge the data gap with advanced ACF integration patterns, ensuring your custom components handle complex relationships, repeaters, and high-performance rendering pipelines efficiently.
Dynamic Tags Integration
Creating Custom Dynamic Tags
Dynamic tags allow you to inject dynamic content (like post titles, custom fields, dates) into widget controls. You create one by extending \Elementor\Core\DynamicTags\Tag and implementing required methods.
class My_Dynamic_Tag extends \Elementor\Core\DynamicTags\Tag { public function get_name() { return 'my-tag'; } public function get_title() { return 'My Tag'; } public function get_group() { return 'site'; } public function get_categories() { return ['text']; } public function render() { echo get_bloginfo('name'); } }
Dynamic Tag Categories
Categories define the data type a dynamic tag returns, determining which controls can use it. Common categories: text, url, image, number, post_meta, gallery, color.
┌─────────────────────────────────────────┐
│ DYNAMIC CATEGORIES │
├──────────┬──────────┬──────────┬────────┤
│ TEXT │ URL │ IMAGE │ NUMBER │
│ strings │ links │ img data │ int │
└──────────┴──────────┴──────────┴────────┘
Dynamic Tag Groups
Groups organize dynamic tags in the editor panel dropdown. Built-in groups include site, post, archive, media, action, author. Custom groups help organize your tags logically.
// Register custom group add_action('elementor/dynamic_tags/register', function($manager) { $manager->register_group('my-group', ['title' => 'My Custom Group']); });
Tag Registration
Tags must be registered with Elementor's dynamic tags manager during the elementor/dynamic_tags/register hook to be available in the editor.
add_action('elementor/dynamic_tags/register', function($dynamic_tags_manager) { require_once('my-dynamic-tag.php'); $dynamic_tags_manager->register(new My_Dynamic_Tag()); });
Render Method
The render() method outputs the dynamic tag's value. It should echo the content directly, not return it. This is where your dynamic data retrieval logic lives.
protected function render() { $post_id = get_the_ID(); $value = get_post_meta($post_id, 'my_custom_field', true); echo esc_html($value); }
get_categories()
Returns an array of category slugs that define what type of data this tag provides. A tag can belong to multiple categories, making it available in various control types.
public function get_categories() { return [ \Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY, \Elementor\Modules\DynamicTags\Module::URL_CATEGORY, ]; }
get_group()
Returns the group slug where this tag appears in the dynamic tags panel. Helps users find related tags together (e.g., all post-related tags under "Post" group).
public function get_group() { return 'post'; // Appears under "Post" group in panel }
get_title()
Returns the human-readable name displayed in the dynamic tags dropdown. Should be translatable using esc_html__() for internationalization support.
public function get_title() { return esc_html__('Custom Field Value', 'my-plugin'); }
get_panel_template_setting_key()
For dynamic tags with settings (like selecting which meta key to display), this returns the primary setting key used for the panel template preview.
public function get_panel_template_setting_key() { return 'meta_key'; // References the control added in _register_controls() }
Dynamic Content Support in Widgets
To enable dynamic tags on widget controls, add the dynamic argument with active => true. This adds the dynamic tag icon to compatible controls.
$this->add_control('title', [ 'label' => 'Title', 'type' => \Elementor\Controls_Manager::TEXT, 'dynamic' => [ 'active' => true, 'categories' => [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY], ], ]);
Custom Controls Development
Extending Base_Control
Custom controls extend \Elementor\Base_Control (for non-data controls) or \Elementor\Base_Data_Control (for controls storing values). This provides the framework for custom UI elements in the editor.
class My_Control extends \Elementor\Base_Data_Control { public function get_type() { return 'my-control'; } // ... other required methods }
Control Template
The template defines the HTML structure rendered in Elementor's panel. Uses Underscore.js templating syntax with <# #> for logic and {{{ }}} for output.
┌──────────────────────────────────┐
│ CONTROL TEMPLATE FLOW │
│ │
│ PHP Class → content_template() │
│ ↓ │
│ Underscore.js Template │
│ ↓ │
│ Rendered HTML in Panel │
└──────────────────────────────────┘
Control Type
A unique string identifier for your control. Must be unique across all Elementor controls. Convention is lowercase with hyphens.
const CONTROL_TYPE = 'my-custom-slider'; public function get_type() { return self::CONTROL_TYPE; }
get_type() Method
Required method returning the unique control type identifier. Used by Elementor to register and reference the control throughout the system.
public function get_type() { return 'color-palette-picker'; // Unique identifier }
content_template() Method
Outputs the Underscore.js template for the control's UI. Uses data object to access control settings and controlValue for current value.
public function content_template() { ?> <div class="elementor-control-field"> <label class="elementor-control-title">{{{ data.label }}}</label> <input type="range" data-setting="{{ data.name }}" min="{{ data.min }}" max="{{ data.max }}" value="{{ data.controlValue }}"> </div> <?php }
get_default_value() Method
Returns the default value for the control when no value is set. Can return scalar values or arrays for complex controls.
public function get_default_value() { return [ 'size' => 50, 'unit' => 'px', ]; }
get_value() Method
Processes and retrieves the stored control value. Can manipulate data before it's used by widgets, useful for data transformation or migration.
public function get_value($control, $settings) { $value = parent::get_value($control, $settings); return is_array($value) ? $value : ['size' => $value, 'unit' => 'px']; }
enqueue() Method
Enqueues CSS/JS assets required by the control in the editor. Called automatically when control is registered.
public function enqueue() { wp_enqueue_script('my-control-js', plugins_url('assets/control.js', __FILE__), ['jquery'], '1.0.0', true ); wp_enqueue_style('my-control-css', plugins_url('assets/control.css', __FILE__) ); }
Control Rendering
Controls render differently in editor (interactive UI) vs frontend (applied styles/values). Editor uses content_template(), while widget's render() uses processed values.
┌─────────────────────────────────────────────────┐
│ CONTROL RENDERING │
├─────────────────────┬───────────────────────────┤
│ EDITOR │ FRONTEND │
├─────────────────────┼───────────────────────────┤
│ content_template() │ $settings['control_id'] │
│ Interactive UI │ Applied CSS/HTML │
│ Backbone.js Views │ PHP render() method │
└─────────────────────┴───────────────────────────┘
Popup Builder Integration
Popup Triggers
Triggers define when a popup opens. Types include: page load, scroll, click, inactivity, exit intent. Each trigger has timing and condition settings.
// Programmatically trigger popup via JS elementorProFrontend.modules.popup.showPopup({id: 123}); // Trigger types: on_page_load, on_scroll, on_click, // on_inactivity, on_exit_intent, on_element_scroll
Popup Actions
Actions define behavior when popup opens/closes. Built-in actions include close popup, open another popup, trigger webhooks. Can be extended programmatically.
// Close popup via dynamic action link <a href="#elementor-action%3Aaction%3Dpopup%3Aclose">Close</a> // Open popup via action link <a href="#elementor-action%3Aaction%3Dpopup%3Aopen%26settings%3DeyJpZCI6IjEyMyJ9"> Open Popup 123 </a>
Dynamic Popup Content
Popups fully support dynamic tags, allowing content to change based on context (current post, user, custom fields). Enables personalized popup experiences.
┌────────────────────────────────────┐
│ DYNAMIC POPUP EXAMPLE │
├────────────────────────────────────┤
│ "Hello, {user:display_name}!" │
│ "Check out: {post:title}" │
│ "Price: {acf:product_price}" │
└────────────────────────────────────┘
Popup Templates
Popups are stored as elementor_library post type with popup template type. Can be created programmatically or imported. Templates support conditions, triggers, and advanced rules.
// Create popup programmatically $popup_id = wp_insert_post([ 'post_title' => 'My Popup', 'post_type' => 'elementor_library', 'post_status' => 'publish', 'meta_input' => [ '_elementor_template_type' => 'popup', ], ]);
Form Builder Integration (Pro)
Custom Form Actions
Actions execute after form submission. Extend \ElementorPro\Modules\Forms\Classes\Action_Base to create custom actions like CRM integration or custom notifications.
class My_Form_Action extends \ElementorPro\Modules\Forms\Classes\Action_Base { public function get_name() { return 'my_action'; } public function get_label() { return 'My Custom Action'; } public function run($record, $ajax_handler) { $fields = $record->get_formatted_data(); // Process form data } public function register_settings_section($widget) { /* Add controls */ } }
Form Validation
Validation occurs server-side before actions run. Use elementor_pro/forms/validation hook to add custom validation logic and return errors.
add_action('elementor_pro/forms/validation', function($record, $ajax_handler) { $fields = $record->get_field(['id' => 'email']); if (!filter_var($fields['value'], FILTER_VALIDATE_EMAIL)) { $ajax_handler->add_error($fields['id'], 'Invalid email format'); } }, 10, 2);
Form Submissions
Form data is captured in the $record object. Access raw data, formatted data, or specific fields. Submissions can be stored, emailed, or sent to external services.
// In action's run() method $raw_fields = $record->get('fields'); $formatted = $record->get_formatted_data(); $email = $record->get_field(['id' => 'email'])['value']; $form_name = $record->get_form_settings('form_name');
Form Field Types
Built-in types: text, email, textarea, URL, tel, number, date, time, select, radio, checkbox, acceptance, upload, hidden, HTML, reCAPTCHA, honeypot.
┌─────────────────────────────────────────┐
│ FORM FIELD TYPES │
├────────────┬────────────┬───────────────┤
│ INPUT │ SELECT │ SPECIAL │
├────────────┼────────────┼───────────────┤
│ text │ select │ upload │
│ email │ radio │ recaptcha │
│ number │ checkbox │ honeypot │
│ tel │ │ hidden │
│ date/time │ │ html │
└────────────┴────────────┴───────────────┘
Action Registration
Register custom form actions during elementor_pro/forms/actions/register hook. Actions appear in form widget's "Actions After Submit" setting.
add_action('elementor_pro/forms/actions/register', function($form_actions_registrar) { require_once('class-my-form-action.php'); $form_actions_registrar->register(new My_Form_Action()); });
Action Execution
The run() method executes when form validates successfully. Receives $record (form data) and $ajax_handler (for responses/errors).
public function run($record, $ajax_handler) { $settings = $record->get('form_settings'); $fields = $record->get('fields'); $response = wp_remote_post('https://api.example.com', [ 'body' => ['data' => $fields] ]); if (is_wp_error($response)) { $ajax_handler->add_error_message('API Error'); } }
Query Control
Query Control Type
A specialized control for selecting posts, terms, or users with AJAX-powered autocomplete. Part of Elementor Pro's query module.
$this->add_control('post_ids', [ 'label' => 'Select Posts', 'type' => \ElementorPro\Modules\QueryControl\Module::QUERY_CONTROL_ID, 'autocomplete' => [ 'object' => \ElementorPro\Modules\QueryControl\Module::QUERY_OBJECT_POST, ], 'multiple' => true, ]);
Post Query
Configure WP_Query arguments through controls. Supports post type, taxonomy filters, orderby, meta queries, and inclusion/exclusion rules.
$this->add_control('posts_query', [ 'type' => 'query', 'post_type' => ['post', 'page'], 'posts_per_page' => 10, 'orderby' => 'date', 'order' => 'DESC', 'meta_query' => [/* ... */], ]);
Term Query
Queries taxonomy terms (categories, tags, custom taxonomies). Uses get_terms() arguments for filtering and sorting.
$this->add_control('term_ids', [ 'type' => \ElementorPro\Modules\QueryControl\Module::QUERY_CONTROL_ID, 'autocomplete' => [ 'object' => \ElementorPro\Modules\QueryControl\Module::QUERY_OBJECT_TAX, 'query' => ['taxonomy' => 'category'], ], ]);
Author Query
Queries WordPress users, typically for author-related widgets. Filters by role, capability, or meta fields.
$this->add_control('author_ids', [ 'type' => \ElementorPro\Modules\QueryControl\Module::QUERY_CONTROL_ID, 'autocomplete' => [ 'object' => \ElementorPro\Modules\QueryControl\Module::QUERY_OBJECT_AUTHOR, 'query' => ['role__in' => ['author', 'editor']], ], ]);
Custom Queries
Build completely custom queries by filtering the query arguments. Useful for complex requirements like geo-queries or external data sources.
add_action('elementor/query/my_custom_query', function($query) { $query->set('post_type', 'product'); $query->set('meta_query', [ ['key' => '_price', 'value' => 100, 'compare' => '<=', 'type' => 'NUMERIC'] ]); $query->set('orderby', 'meta_value_num'); $query->set('meta_key', '_price'); });
Query Filters
Hooks to modify queries before execution. elementor/query/{query_id} allows targeted modifications to specific widget queries.
// Filter specific query by ID set in widget add_action('elementor/query/featured_products', function($query) { $query->set('meta_query', [ ['key' => '_featured', 'value' => 'yes'] ]); }); // Filter all Elementor post queries add_action('elementor_pro/posts/query', function($query, $widget) { // Modify all post widget queries }, 10, 2);
┌──────────────────────────────────────────────────────────┐
│ ELEMENTOR DEVELOPMENT ARCHITECTURE │
├──────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ WIDGETS │◄──►│ CONTROLS │◄──►│ DYNAMIC │ │
│ │ │ │ │ │ TAGS │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └──────────────────┼──────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ ELEMENTOR CORE │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ POPUPS │ │ FORMS │ │ QUERIES │ │
│ │ (Pro) │ │ (Pro) │ │ (Pro) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└──────────────────────────────────────────────────────────┘
Conditions System
Display Conditions
Display conditions control when templates, widgets, or sections appear based on specific criteria like user authentication, date/time, or page type—they're the gatekeepers of your content visibility in Elementor Pro's Theme Builder.
// Register custom display condition add_action('elementor/theme/register_conditions', function($conditions_manager) { $conditions_manager->register_condition(new My_Custom_Condition()); });
Condition Groups
Condition groups organize related conditions into logical categories (like "General," "Archive," "Singular") within the display conditions popup, using AND/OR operators to combine multiple rules for complex targeting scenarios.
┌─────────────────────────────────────┐
│ Include: Singular → Posts → All │ ← Group 1
├─────────────────────────────────────┤
│ AND: User → Logged In │
├─────────────────────────────────────┤
│ OR │
├─────────────────────────────────────┤
│ Include: Singular → Pages → About │ ← Group 2
└─────────────────────────────────────┘
Custom Conditions
Custom conditions extend Elementor's native condition system by creating your own PHP class that defines unique targeting logic, such as checking WooCommerce cart contents, user meta, or custom post meta.
class My_Custom_Condition extends \ElementorPro\Modules\ThemeBuilder\Conditions\Condition_Base { public function get_name() { return 'my_condition'; } public function get_label() { return 'My Custom Condition'; } public function check($args) { return get_user_meta(get_current_user_id(), 'is_premium', true) === 'yes'; } }
Condition Callbacks
Condition callbacks are the actual PHP functions that evaluate whether a condition is met at runtime, returning true or false to determine if the associated template should be displayed for the current request.
public function check($args) { // Callback logic - return boolean $post_id = get_the_ID(); return has_term('featured', 'category', $post_id); }
Elementor Pro Integration
Theme Builder Locations
Theme Builder locations are predefined template slots (header, footer, single, archive, etc.) where your custom templates are injected; you register them to tell WordPress and Elementor where your theme supports dynamic content replacement.
// In theme's functions.php add_action('elementor/theme/register_locations', function($manager) { $manager->register_all_core_location(); // Or custom: $manager->register_location('custom-sidebar', [ 'label' => 'Custom Sidebar', 'multiple' => true, ]); });
┌──────────────────────────────────┐
│ HEADER LOCATION │
├──────────────────────────────────┤
│ │
│ SINGLE LOCATION │
│ (Post/Page Content) │
│ │
├──────────────────────────────────┤
│ FOOTER LOCATION │
└──────────────────────────────────┘
Dynamic Content
Dynamic Content allows widgets to pull data from post fields, custom fields (ACF, Toolset, Pods), site info, or user data using Dynamic Tags—replacing static content with database-driven values at render time.
// Create custom dynamic tag class My_Dynamic_Tag extends \Elementor\Core\DynamicTags\Tag { public function get_name() { return 'my-tag'; } public function get_categories() { return ['text']; } public function render() { echo get_post_meta(get_the_ID(), 'custom_field', true); } }
Custom Skin Development
Custom skins provide alternative visual representations for existing Pro widgets (like Posts, Portfolio) without rebuilding the entire widget, allowing you to override just the rendering layer while inheriting all widget controls.
class My_Posts_Skin extends \ElementorPro\Modules\Posts\Skins\Skin_Base { public function get_id() { return 'my-custom-skin'; } public function get_title() { return 'My Custom Layout'; } public function render() { // Custom HTML output for posts grid } }
Loop Builder Integration
Loop Builder (introduced in Elementor 3.8+) enables creating custom loop item templates that integrate with any query, replacing the old Posts widget approach with a more flexible container-based looping system.
// Register custom query for Loop Builder add_action('elementor/query/my_custom_query', function($query) { $query->set('post_type', 'product'); $query->set('posts_per_page', 6); $query->set('meta_key', 'featured'); $query->set('meta_value', 'yes'); });
Widget Skins
Skin Base Class
Skin_Base is the abstract class all widget skins must extend, providing the foundation methods for registering controls, handling AJAX, and rendering output while maintaining connection to the parent widget.
abstract class Skin_Base extends \Elementor\Skin_Base { abstract public function get_id(); // Unique skin ID abstract public function get_title(); // Display name abstract public function render(); // Output HTML protected function _register_controls_actions() { // Hook into parent widget's control stack } }
Multiple Skins per Widget
A single widget can have multiple registered skins that users select from a dropdown, each providing completely different markup while sharing the same base controls—think "Classic," "Cards," and "Carousel" variations.
┌─────────────────────────────┐
│ Widget: My Posts Widget │
├─────────────────────────────┤
│ Skin: [Dropdown ▼] │
│ ├── Classic Grid │
│ ├── Masonry Layout │
│ ├── Card Style │
│ └── Full Width │
└─────────────────────────────┘
Skin Controls
Skin-specific controls are registered within the skin class and only appear when that skin is active, allowing each skin to have unique settings without cluttering other skins' panels.
protected function _register_controls_actions() { add_action('elementor/element/my-widget/section_layout/before_section_end', [$this, 'register_skin_controls']); } public function register_skin_controls() { $this->add_control('columns', [ 'label' => 'Columns', 'type' => \Elementor\Controls_Manager::SELECT, 'default' => '3', 'options' => ['2' => '2', '3' => '3', '4' => '4'], 'condition' => ['_skin' => $this->get_id()], // Only show for this skin ]); }
Skin Rendering
Skin rendering occurs in the render() method where you output the final HTML; it has access to parent widget settings via $this->parent->get_settings() and should handle both editor preview and frontend display.
public function render() { $settings = $this->parent->get_settings_for_display(); $posts = $this->parent->get_query()->posts; echo '<div class="my-skin-wrapper columns-' . $settings['columns'] . '">'; foreach ($posts as $post) { echo '<article class="skin-item">'; echo '<h3>' . esc_html($post->post_title) . '</h3>'; echo '</article>'; } echo '</div>'; }
Skin Registration
Skins are registered by hooking into the elementor/widget/{widget-name}/skins_init action and calling add_skin() on the widget instance, typically done during plugin initialization.
add_action('elementor/widget/posts/skins_init', function($widget) { $widget->add_skin(new My_Custom_Posts_Skin($widget)); }); // Or for your own widget, in the widget class: protected function register_skins() { $this->add_skin(new Skin_Classic($this)); $this->add_skin(new Skin_Cards($this)); }
Document Types
Custom Document Types
Custom document types extend Elementor beyond pages/posts to handle specialized content like popups, landing pages, or custom templates—each with their own settings panel, template library category, and behavior rules.
class My_Document extends \Elementor\Core\Base\Document { public static function get_type() { return 'my-document'; } public static function get_title() { return 'My Custom Document'; } public static function get_properties() { return [ 'has_elements' => true, 'is_editable' => true, 'support_kit' => true, ]; } } // Register add_action('elementor/documents/register', function($manager) { $manager->register_document_type('my-document', My_Document::class); });
Document Settings
Document settings are the controls in the bottom-left gear panel, allowing per-document configuration like page layout, custom CSS, background, and status—registered via register_controls() in your document class.
protected function register_controls() { $this->start_controls_section('my_settings', [ 'label' => 'My Settings', 'tab' => \Elementor\Controls_Manager::TAB_SETTINGS, ]); $this->add_control('custom_option', [ 'label' => 'Enable Feature', 'type' => \Elementor\Controls_Manager::SWITCHER, 'default' => 'yes', ]); $this->end_controls_section(); }
Document Templates
Document templates define the base HTML wrapper structure for your document type, controlling the overall page skeleton, enqueued assets, and body classes specific to that document type.
public function print_content() { $plugin = \Elementor\Plugin::instance(); ?> <div class="my-document-wrapper" data-doc-id="<?php echo $this->get_id(); ?>"> <?php echo $plugin->frontend->get_builder_content($this->get_id(), true); ?> </div> <?php } public function get_css_wrapper_selector() { return '.my-document-wrapper'; }
Document Locations
Document locations define where templates can be applied in Theme Builder, connecting your custom document type to specific WordPress template hierarchy positions like single posts, archives, or custom theme areas.
public static function get_properties() { return [ 'location' => 'single', // Can be applied to single post templates 'support_conditions' => true, ]; } // Map to theme location add_action('elementor/theme/register_locations', function($manager) { $manager->register_location('my-location', [ 'label' => 'My Custom Location', 'edit_in_content' => true, ]); });
Elementor Editor Extensions
Panel Extensions
Panel extensions allow adding custom tabs, sections, or entirely new functionality to Elementor's left sidebar using JavaScript hooks and the Marionette.js framework that powers the editor UI.
// Add custom panel tab elementor.hooks.addFilter('panel/elements/regionViews', function(regionViews) { regionViews['custom-tab'] = { region: 'custom', view: MyCustomPanelView }; return regionViews; });
┌─────────────────┐
│ ☰ ELEMENTS │ ← Panel Header
├─────────────────┤
│ [Widget Search] │
├─────────────────┤
│ Basic │
│ ├── Heading │ ← Panel Body
│ ├── Image │
│ └── Text │
├─────────────────┤
│ [⚙️] [📱] [👁️] │ ← Panel Footer
└─────────────────┘
Context Menu Extensions
Context menu extensions add custom options to the right-click menu on widgets, sections, or columns—useful for adding shortcuts to complex operations or custom functionality.
elementor.hooks.addFilter('elements/widget/contextMenuGroups', function(groups, view) { groups.push({ name: 'my-actions', actions: [ { name: 'duplicate-styled', title: 'Duplicate with Styles', icon: 'eicon-clone', callback: function() { // Custom duplication logic } } ] }); return groups; });
Panel Footer Extensions
Panel footer extensions add buttons or controls to the bottom bar of the editor panel, alongside existing buttons like responsive mode, preview, and settings.
elementor.hooks.addAction('panel/footer/render', function() { const $footer = elementor.panel.$el.find('#elementor-panel-footer'); $footer.append(` <div id="my-footer-button" class="elementor-panel-footer-tool"> <i class="eicon-code"></i> <span class="elementor-screen-only">My Tool</span> </div> `); });
Panel Header Extensions
Panel header extensions modify the top bar of the editor panel, where you can add custom buttons next to the hamburger menu or modify existing header behavior.
elementor.on('panel:init', function() { const headerView = elementor.panel.currentView.header; headerView.$el.find('.elementor-panel-header-menu-button') .after('<div class="my-header-tool" title="Custom Tool">★</div>'); });
Navigator Extensions
Navigator extensions enhance the layer navigator panel (the tree view showing all elements), allowing custom icons, actions, or information display for elements.
elementor.hooks.addFilter('navigator/element/title', function(title, model) { if (model.get('elType') === 'widget' && model.get('widgetType') === 'my-widget') { return '🚀 ' + title; // Custom prefix } return title; });
Performance Optimization
Lazy Loading
Lazy loading defers resource loading until needed—for widgets, you can prevent CSS/JS from loading until the widget scrolls into viewport using Intersection Observer or native loading attributes for images.
public function render() { ?> <div class="lazy-widget" data-lazy-src="<?php echo $this->get_settings('image'); ?>"> <noscript> <img src="<?php echo esc_url($image_url); ?>" /> </noscript> </div> <?php } // Frontend JS const observer = new IntersectionObserver((entries) => { entries.forEach(e => { if(e.isIntersecting) loadWidget(e.target); }); });
Asset Optimization
Asset optimization minimizes widget CSS/JS footprint by combining files, minifying code, and using Elementor's built-in asset optimization settings (found in Elementor → Settings → Experiments).
// Register optimized assets public function get_script_depends() { return ['my-widget-min']; // Reference minified version } public function get_style_depends() { return ['my-widget-critical']; // Critical CSS only } // Conditionally register wp_register_script('my-widget-min', plugin_url('assets/js/widget.min.js'), [], '1.0', true // true = in footer );
Conditional Asset Loading
Conditional asset loading ensures widget CSS/JS only loads on pages where the widget is actually used, preventing bloat on pages that don't need those resources.
class My_Widget extends \Elementor\Widget_Base { public function __construct($data = [], $args = null) { parent::__construct($data, $args); // Only register if widget is used add_action('elementor/frontend/after_enqueue_styles', [$this, 'conditional_styles']); } public function conditional_styles() { if ($this->is_widget_used_on_page()) { wp_enqueue_style('my-widget-styles'); } } }
┌─────────────────────────────────────────────┐
│ Page Load Analysis │
├─────────────────────────────────────────────┤
│ WITHOUT Conditional Loading: │
│ [████████████████████] 450KB │
│ │
│ WITH Conditional Loading: │
│ [████████] 180KB ↓60% │
└─────────────────────────────────────────────┘
Caching Strategies
Caching in Elementor involves CSS file caching (regenerated on save), object caching for database queries, and page caching compatibility—clear via Elementor → Tools → Regenerate CSS.
// Custom query caching in widget public function get_cached_data() { $cache_key = 'my_widget_' . md5(serialize($this->get_settings())); $data = wp_cache_get($cache_key, 'elementor'); if (false === $data) { $data = $this->fetch_expensive_data(); wp_cache_set($cache_key, $data, 'elementor', HOUR_IN_SECONDS); } return $data; } // Clear on save add_action('elementor/editor/after_save', function($post_id) { wp_cache_delete('my_widget_' . $post_id, 'elementor'); });
Critical CSS
Critical CSS extracts above-the-fold styles and inlines them in the <head> for faster initial paint, while deferring non-critical styles—Elementor handles this partially but custom widgets may need manual optimization.
// Inline critical styles in head add_action('wp_head', function() { if (is_elementor_page()) { echo '<style id="critical-css">'; echo '.hero-widget { min-height: 100vh; display: flex; }'; echo '.widget-skeleton { background: #f0f0f0; animation: pulse 1.5s infinite; }'; echo '</style>'; } }, 1); // Defer full stylesheet add_filter('style_loader_tag', function($html, $handle) { if ($handle === 'my-widget-full') { return str_replace("rel='stylesheet'", "rel='preload' as='style' onload=\"this.rel='stylesheet'\"", $html); } return $html; }, 10, 2);
JavaScript Defer/Async
Defer/async loading prevents JavaScript from blocking page render—use defer for scripts that need DOM ready, async for independent scripts, and place non-critical widget JS in the footer.
// Add defer/async to widget scripts add_filter('script_loader_tag', function($tag, $handle, $src) { $defer_scripts = ['my-widget-main', 'my-widget-animation']; $async_scripts = ['my-widget-analytics']; if (in_array($handle, $defer_scripts)) { return str_replace(' src', ' defer src', $tag); } if (in_array($handle, $async_scripts)) { return str_replace(' src', ' async src', $tag); } return $tag; }, 10, 3);
Render Timeline:
─────────────────────────────────────────────────
Normal: [HTML]──[CSS]──[JS BLOCKING]──[Render]
Defer: [HTML]──[CSS]──[Render]──[JS]
Async: [HTML]──[CSS] [JS]────[Render]
└──────────┘ (parallel)
─────────────────────────────────────────────────
Testing & Debugging
Widget Unit Testing
Widget unit testing involves creating PHPUnit tests for widget functionality, mocking Elementor's environment to test control logic, rendering output, and data processing independently.
class My_Widget_Test extends WP_UnitTestCase { private $widget; public function setUp(): void { parent::setUp(); $this->widget = new My_Custom_Widget(['settings' => [ 'title' => 'Test Title', 'columns' => 3 ]]); } public function test_widget_renders_title() { ob_start(); $this->widget->render(); $output = ob_get_clean(); $this->assertStringContainsString('Test Title', $output); $this->assertStringContainsString('columns-3', $output); } }
Visual Regression Testing
Visual regression testing captures screenshots of widgets in various states and compares them against baselines to detect unintended visual changes—tools like Percy, BackstopJS, or Playwright work well with Elementor.
// BackstopJS config
{ "scenarios": [ { "label": "My Widget - Desktop", "url": "http://localhost/test-page/", "selectors": [".elementor-widget-my-widget"], "viewports": [ {"width": 1920, "height": 1080}, {"width": 768, "height": 1024}, {"width": 375, "height": 667} ] } ] }
Debug Mode
Elementor's debug mode (enable via define('ELEMENTOR_DEBUG', true) in wp-config.php) loads unminified assets, shows detailed error messages, and enables the Safe Mode option in Tools for troubleshooting.
// wp-config.php define('ELEMENTOR_DEBUG', true); define('SCRIPT_DEBUG', true); define('WP_DEBUG', true); define('WP_DEBUG_LOG', true); // Check if debug mode in widget if (defined('ELEMENTOR_DEBUG') && ELEMENTOR_DEBUG) { error_log('Widget settings: ' . print_r($this->get_settings(), true)); }
Console Logging
Console logging in Elementor's editor uses the browser console for JavaScript debugging, while PHP errors route to wp-content/debug.log; use Elementor's built-in console.log wrapper for editor-side debugging.
// Editor-side debugging elementor.hooks.addAction('panel/open_editor/widget', function(panel, model, view) { console.group('Widget Debug: ' + model.get('widgetType')); console.log('Settings:', model.get('settings').toJSON()); console.log('Element ID:', model.get('id')); console.groupEnd(); }); // In widget JS elementorFrontend.hooks.addAction('frontend/element_ready/my-widget.default', ($element) => { console.table($element.data('settings')); });
Error Handling
Robust error handling in widgets prevents white screens and broken pages—wrap rendering in try/catch, validate inputs, and provide fallback content when external data sources fail.
public function render() { try { $settings = $this->get_settings_for_display(); if (empty($settings['data_source'])) { throw new \Exception('Data source not configured'); } $data = $this->fetch_data($settings['data_source']); $this->render_widget($data); } catch (\Exception $e) { if (current_user_can('manage_options')) { echo '<div class="widget-error">'; echo 'Widget Error: ' . esc_html($e->getMessage()); echo '</div>'; } // Log for production error_log('My Widget Error: ' . $e->getMessage()); } }
Error Flow:
┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Widget │ ──▶ │ Try │ ──▶ │ Success │
│ Render │ │ Render │ │ Output │
└──────────┘ └───────────┘ └──────────────┘
│
▼ (exception)
┌───────────┐ ┌──────────────┐
│ Catch │ ──▶ │ Fallback + │
│ Error │ │ Log Error │
└───────────┘ └──────────────┘
ACF Basics
Field Groups
Field Groups are containers that organize related custom fields together and control where they appear in the WordPress admin. They act as the parent structure for all your custom fields, allowing you to bundle fields logically (e.g., "Product Details" group containing price, SKU, and inventory fields) and assign display rules.
┌─────────────────────────────────────────────┐
│ FIELD GROUP: "Product Details" │
├─────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Field: Price │ │ Field: SKU │ │
│ └─────────────────┘ └─────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Field: Stock │ │ Field: Weight │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────┘
Field Types
ACF provides 30+ field types ranging from basic (text, number, email) to complex (repeater, flexible content, gallery), each designed to capture specific data formats and provide appropriate admin UI controls for content editors.
BASIC TYPES CONTENT TYPES RELATIONAL TYPES
├── Text ├── Image ├── Link
├── Text Area ├── File ├── Post Object
├── Number ├── WYSIWYG ├── Relationship
├── Email ├── oEmbed ├── Taxonomy
├── URL └── Gallery └── User
├── Password
└── Range LAYOUT TYPES CHOICE TYPES
├── Repeater ├── Select
├── Flexible Content ├── Checkbox
├── Group ├── Radio
└── Clone └── True/False
Location Rules
Location Rules determine where field groups appear in the admin using conditional logic with AND/OR operators—you can target specific post types, page templates, user roles, taxonomy terms, or even specific Elementor templates.
// Location rules are set in ACF UI, but programmatically: acf_add_local_field_group([ 'key' => 'group_product', 'title' => 'Product Fields', 'location' => [ // OR condition (outer array) [ // AND conditions (inner array) ['param' => 'post_type', 'operator' => '==', 'value' => 'product'], ['param' => 'page_template', 'operator' => '!=', 'value' => 'archive'], ], [ ['param' => 'post_type', 'operator' => '==', 'value' => 'page'], ['param' => 'page_template', 'operator' => '==', 'value' => 'product-page.php'], ], ], ]);
Field Settings
Each field type has configurable settings including field label, name (slug), instructions, required status, default value, placeholder, character limits, conditional logic, and wrapper attributes (width, class, ID) for admin styling.
┌──────────────────────────────────────────────────┐
│ FIELD SETTINGS PANEL │
├──────────────────────────────────────────────────┤
│ Label: [Product Price ] │
│ Name: [product_price ] (slug) │
│ Instructions: [Enter price in USD ] │
│ Required: [✓] │
│ Default: [0.00 ] │
├──────────────────────────────────────────────────┤
│ VALIDATION │
│ Min Value: [0 ] Max Value: [99999] │
│ Prepend: [$ ] Append: [USD ] │
├──────────────────────────────────────────────────┤
│ CONDITIONAL LOGIC │
│ Show if: [Has Price] [equals] [Yes] │
└──────────────────────────────────────────────────┘
ACF with Elementor
ACF Dynamic Tags
ACF Dynamic Tags allow you to connect any ACF field directly to Elementor widget properties without code—click the dynamic tag icon in any supported widget field, select ACF, and choose your custom field to display dynamic content.
┌─────────────────────────────────────────────────────┐
│ ELEMENTOR HEADING WIDGET │
├─────────────────────────────────────────────────────┤
│ Title: [ ] 🔄 ← Dynamic Tag Icon │
│ ┌─────────────────────────┐ │
│ │ Dynamic Tags │ │
│ ├─────────────────────────┤ │
│ │ 📁 ACF │ │
│ │ ├── ACF Field │ ← Any field │
│ │ ├── ACF URL │ ← URL fields │
│ │ ├── ACF Image │ ← Image fields │
│ │ └── ACF Color │ ← Color fields │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────┘
ACF Repeater Integration
Repeater fields can be displayed in Elementor using the Loop Builder or by creating template sections that iterate through repeater rows, perfect for testimonials, team members, pricing tables, or any repeating content structure.
// In a custom Elementor widget or code block: if( have_rows('team_members') ): echo '<div class="team-grid">'; while( have_rows('team_members') ): the_row(); $name = get_sub_field('name'); $role = get_sub_field('role'); $photo = get_sub_field('photo'); ?> <div class="team-member"> <img src="<?php echo $photo['url']; ?>" alt="<?php echo $name; ?>"> <h3><?php echo $name; ?></h3> <p><?php echo $role; ?></p> </div> <?php endwhile; echo '</div>'; endif;
ACF Flexible Content
Flexible Content fields allow content editors to build pages using predefined layout blocks (similar to a simplified page builder), which can then be rendered within Elementor templates using custom code or loop templates.
┌─────────────────────────────────────────────────────┐
│ FLEXIBLE CONTENT: Page Builder │
├─────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────┐ │
│ │ Layout: Hero Banner [↑] [↓] [×] │ │
│ │ ├── Background Image: [selected] │ │
│ │ ├── Heading: "Welcome" │ │
│ │ └── CTA Button: "Learn More" │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Layout: Two Column [↑] [↓] [×] │ │
│ │ ├── Left Content: [WYSIWYG] │ │
│ │ └── Right Image: [selected] │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Layout: Testimonials Slider [↑] [↓] [×] │ │
│ │ └── Select Testimonials: [3 selected] │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ [+ Add Layout] → Hero | Two Column | CTA | Gallery │
└─────────────────────────────────────────────────────┘
// Rendering flexible content in template if( have_rows('page_sections') ): while( have_rows('page_sections') ): the_row(); $layout = get_row_layout(); get_template_part('layouts/acf', $layout); endwhile; endif;
ACF Gallery Integration
ACF Gallery fields store multiple images that can be displayed in Elementor using dynamic gallery widgets, image carousels, or by mapping the gallery field to Elementor's native gallery widget via dynamic tags.
// Display ACF gallery in Elementor template/widget $gallery = get_field('product_gallery'); if( $gallery ): ?> <div class="acf-gallery elementor-gallery"> <?php foreach( $gallery as $image ): ?> <div class="gallery-item"> <a href="<?php echo $image['url']; ?>" data-elementor-lightbox="yes"> <img src="<?php echo $image['sizes']['medium']; ?>" alt="<?php echo $image['alt']; ?>" /> </a> </div> <?php endforeach; ?> </div> <?php endif;
ACF Relationship Fields
Relationship and Post Object fields create connections between content (e.g., linking related products, authors to books), which can be displayed in Elementor using Posts widgets with dynamic queries or custom loop templates.
// Display related posts from ACF relationship field $related_posts = get_field('related_articles'); if( $related_posts ): ?> <div class="related-articles"> <h3>Related Articles</h3> <div class="posts-grid"> <?php foreach( $related_posts as $post ): setup_postdata( $post ); ?> <article class="related-post"> <?php echo get_the_post_thumbnail($post->ID, 'thumbnail'); ?> <h4><a href="<?php echo get_permalink(); ?>"> <?php echo get_the_title(); ?> </a></h4> </article> <?php endforeach; wp_reset_postdata(); ?> </div> </div> <?php endif;
Custom ACF Integration
ACF in Custom Widgets
Within custom Elementor widgets, you can retrieve ACF field values using get_field() in the render() method, allowing you to build widgets that dynamically display custom field data from the current post or any specified post ID.
class ACF_Product_Card_Widget extends \Elementor\Widget_Base { public function get_name() { return 'acf_product_card'; } public function get_title() { return 'ACF Product Card'; } protected function render() { $post_id = get_the_ID(); // Get ACF fields $price = get_field('product_price', $post_id); $sku = get_field('product_sku', $post_id); $stock = get_field('stock_status', $post_id); $gallery = get_field('product_gallery', $post_id); ?> <div class="product-card"> <div class="product-gallery"> <?php if($gallery): ?> <img src="<?php echo $gallery[0]['url']; ?>" alt=""> <?php endif; ?> </div> <h3><?php the_title(); ?></h3> <p class="price">$<?php echo number_format($price, 2); ?></p> <p class="sku">SKU: <?php echo $sku; ?></p> <span class="stock-badge <?php echo $stock; ?>"> <?php echo ucfirst($stock); ?> </span> </div> <?php } }
Dynamic ACF Controls
You can create Elementor widget controls that dynamically populate options from ACF fields—such as a select dropdown listing all ACF field groups or a control pre-filled with ACF repeater values—using the get_field_objects() function.
protected function register_controls() { $this->start_controls_section('content_section', [ 'label' => 'Content', ]); // Dynamic dropdown from ACF choices $team_options = []; if( have_rows('team_members', 'option') ) { while( have_rows('team_members', 'option') ): the_row(); $name = get_sub_field('name'); $team_options[$name] = $name; endwhile; } $this->add_control('selected_member', [ 'label' => 'Select Team Member', 'type' => \Elementor\Controls_Manager::SELECT, 'options' => $team_options, ]); // Dynamically list all ACF fields from a field group $this->add_control('acf_field', [ 'label' => 'Select ACF Field', 'type' => \Elementor\Controls_Manager::SELECT, 'options' => $this->get_acf_field_options(), ]); $this->end_controls_section(); } private function get_acf_field_options() { $options = []; $field_groups = acf_get_field_groups(); foreach($field_groups as $group) { $fields = acf_get_fields($group['key']); foreach($fields as $field) { $options[$field['name']] = $field['label']; } } return $options; }
ACF Options Pages
Options Pages provide global settings accessible across your entire site (not tied to specific posts), perfect for storing site-wide data like social media links, footer content, or company information that Elementor templates can access via get_field('field_name', 'option').
// Register ACF Options Page if( function_exists('acf_add_options_page') ) { // Main options page acf_add_options_page([ 'page_title' => 'Theme Settings', 'menu_title' => 'Theme Settings', 'menu_slug' => 'theme-settings', 'capability' => 'edit_posts', 'icon_url' => 'dashicons-admin-settings', 'redirect' => false ]); // Sub pages acf_add_options_sub_page([ 'page_title' => 'Header Settings', 'menu_title' => 'Header', 'parent_slug' => 'theme-settings', ]); acf_add_options_sub_page([ 'page_title' => 'Footer Settings', 'menu_title' => 'Footer', 'parent_slug' => 'theme-settings', ]); } // Access in Elementor widget/template $phone = get_field('company_phone', 'option'); $social = get_field('social_links', 'option'); // Repeater $logo = get_field('site_logo', 'option'); // Image
WP ADMIN MENU
├── Dashboard
├── Posts
├── Pages
├── Theme Settings ← Main Options Page
│ ├── Header ← Sub Page
│ ├── Footer ← Sub Page
│ └── Social Media ← Sub Page
└── ...
ACF Block Types
ACF Blocks let you create custom Gutenberg blocks using ACF fields as the interface, which can coexist with Elementor—useful for clients who need simple block-based editing in some areas while maintaining Elementor for complex layouts.
// Register ACF Block add_action('acf/init', 'register_acf_blocks'); function register_acf_blocks() { if( function_exists('acf_register_block_type') ) { acf_register_block_type([ 'name' => 'testimonial', 'title' => 'Testimonial', 'description' => 'A custom testimonial block.', 'render_template' => 'blocks/testimonial.php', 'category' => 'formatting', 'icon' => 'format-quote', 'keywords' => ['testimonial', 'quote'], 'supports' => [ 'align' => true, 'jsx' => true, // Enable InnerBlocks ], ]); } }
// blocks/testimonial.php - Block Template <?php $quote = get_field('quote'); $author = get_field('author_name'); $photo = get_field('author_photo'); $rating = get_field('rating'); ?> <div class="acf-block-testimonial"> <blockquote> <?php echo $quote; ?> </blockquote> <div class="author"> <?php if($photo): ?> <img src="<?php echo $photo['sizes']['thumbnail']; ?>" alt="<?php echo $author; ?>"> <?php endif; ?> <cite><?php echo $author; ?></cite> <div class="rating"> <?php echo str_repeat('★', $rating); ?> </div> </div> </div>
┌─────────────────────────────────────────────────────┐
│ ACF BLOCK vs ELEMENTOR DECISION FLOW │
├─────────────────────────────────────────────────────┤
│ │
│ Need complex layouts? ───YES──→ Use Elementor │
│ │ │
│ NO │
│ │ │
│ ▼ │
│ Simple, reusable ───YES──→ Use ACF Blocks │
│ content structure? │
│ │ │
│ NO │
│ │ │
│ ▼ │
│ Hybrid: ACF Blocks for content areas within │
│ Elementor template layouts │
│ │
└─────────────────────────────────────────────────────┘