magento2-widget-creation
π―Skillfrom proxiblue/claude-skills
Develops custom, configurable Magento 2 widgets for CMS pages and blocks with comprehensive module structure and implementation guidance.
Part of
proxiblue/claude-skills(4 items)
Installation
npx skills add proxiblue/claude-skills --skill magento2-widget-creationSkill Details
Comprehensive guide for creating custom widget modules in Magento 2 that can be inserted into CMS pages and blocks. Covers module structure, widget configuration, templates, JavaScript, CSS, and form submission handling for non-HyvΓ€ themes.
Overview
# Magento 2 Widget Creation for CMS Pages
Purpose
This skill provides comprehensive guidance on creating custom widget modules in Magento 2 (standard Luma/Blank themes, not HyvΓ€-based) that can be inserted into CMS pages, CMS blocks, or any content area using the widget system.
When to Use This Skill
Use this skill when you need to:
- Create a reusable component that can be inserted into CMS pages
- Build interactive elements (buttons, forms, modals) for content editors
- Develop custom functionality that non-technical users can add to pages
- Create widgets with configurable parameters that appear in the admin panel
- Implement widgets that work with standard Magento themes (Luma/Blank)
Do NOT use this skill for:
- HyvΓ€ theme widgets (use hyva-tailwind-integration skill instead)
- Backend admin widgets
- UI components or admin grids
Prerequisites
- Existing vendor namespace or willingness to create one
- Basic understanding of Magento 2 module structure
- Knowledge of XML configuration
- Familiarity with Magento templates and blocks
- Understanding of JavaScript widget pattern (optional, for interactive widgets)
Widget Module Structure
A complete widget module requires these components:
```
app/code/Vendor/ModuleName/
βββ registration.php # Module registration
βββ etc/
β βββ module.xml # Module configuration
β βββ widget.xml # Widget definition
β βββ email_templates.xml # (Optional) Email templates
β βββ frontend/
β βββ routes.xml # (Optional) For form submissions
βββ Block/
β βββ Widget/
β βββ WidgetName.php # Widget block class
βββ Controller/ # (Optional) For form handlers
β βββ Index/
β βββ Submit.php
βββ view/frontend/
βββ templates/
β βββ widget/
β βββ template.phtml # Widget template
βββ layout/
β βββ default.xml # (Optional) Load CSS/JS globally
βββ web/
β βββ js/
β β βββ widget-script.js # (Optional) Custom JS
β βββ css/
β βββ widget-style.css # (Optional) Custom CSS
βββ requirejs-config.js # (Optional) JS module mapping
βββ email/ # (Optional) Email templates
βββ template.html
```
Step-by-Step Widget Creation
Step 1: Create Module Registration
File: registration.php
```php
/**
* Copyright Β© Vendor. All rights reserved.
*/
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Vendor_ModuleName',
__DIR__
);
```
Step 2: Create Module Configuration
File: etc/module.xml
```xml
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
```
Key Points:
setup_versionis legacy but still commonly used- Add dependencies in
-Magento_CmsandMagento_Widgetare required for widgets - Add other modules your widget depends on (e.g.,
Magento_Email,Magento_Catalog)
Step 3: Create Widget Configuration
File: etc/widget.xml
This is where you define your widget's metadata and configurable parameters.
```xml
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Widget:etc/widget.xsd">
source_model="Magento\Config\Model\Config\Source\Yesno">
source_model="Magento\Catalog\Model\Category\Attribute\Source\Categories">
```
Widget Parameter Types:
text- Simple text inputselect- Dropdown selectionmultiselect- Multiple selectionsblock- CMS block pickerpage- CMS page pickerconditions- Product/category conditions (advanced)
Important Attributes:
id- Unique identifier for the widgetclass- Full namespaced path to your block classrequired- Whether parameter is mandatoryvisible- Whether parameter shows in admin
Step 4: Create Block Class
File: Block/Widget/WidgetName.php
The block class handles the widget's logic and data.
```php
/**
* Copyright Β© Vendor. All rights reserved.
*/
declare(strict_types=1);
namespace Vendor\ModuleName\Block\Widget;
use Magento\Framework\View\Element\Template;
use Magento\Widget\Block\BlockInterface;
use Magento\Framework\View\Element\Template\Context;
class WidgetName extends Template implements BlockInterface
{
/**
* Template path relative to view/frontend/templates/
*
* @var string
*/
protected $_template = 'Vendor_ModuleName::widget/template.phtml';
/**
* @param Context $context
* @param array $data
*/
public function __construct(
Context $context,
array $data = []
) {
parent::__construct($context, $data);
}
/**
* Get widget parameter with default value
*
* @return string
*/
public function getParameterValue(): string
{
return $this->getData('text_param') ?: 'default value';
}
/**
* Get widget select parameter
*
* @return string
*/
public function getSelectValue(): string
{
return $this->getData('select_param') ?: 'value1';
}
/**
* Check if feature is enabled
*
* @return bool
*/
public function isEnabled(): bool
{
return (bool)$this->getData('enabled');
}
/**
* Get URL for AJAX or form submission
*
* @return string
*/
public function getActionUrl(): string
{
return $this->getUrl('modulename/index/submit');
}
}
```
Best Practices:
- Always
declare(strict_types=1); - Implement
BlockInterface - Use
$this->getData('param_name')to access widget parameters - Provide default values with
?:operator - Add type hints and return types
- Keep business logic out of templates - put it in block methods
Step 5: Create Template File
File: view/frontend/templates/widget/template.phtml
Templates render the HTML output. Always escape data for security.
```php
/**
* Copyright Β© Vendor. All rights reserved.
*
* @var $block Vendor\ModuleName\Block\Widget\WidgetName
* @var $escaper Magento\Framework\Escaper
*/
$paramValue = $block->getParameterValue();
$selectValue = $block->getSelectValue();
$isEnabled = $block->isEnabled();
$uniqueId = uniqid('widget_');
?>
= $escaper->escapeHtml($paramValue) ?> class="action primary widget-button" data-mage-init='{"widgetName": {"elementId": "= $escaper->escapeJs($uniqueId) ?>"}}'> = $escaper->escapeHtml(__('Click Me')) ?> = $escaper->escapeHtml(__('Widget Title')) ?>
```
Template Best Practices:
- Always use
$escaper->escapeHtml()for text content - Use
$escaper->escapeHtmlAttr()for HTML attributes - Use
$escaper->escapeJs()for JavaScript strings - Use
$escaper->escapeUrl()for URLs - Use
__()for translatable strings - Generate unique IDs to avoid conflicts (use
uniqid()) - Add proper
@varcomments for IDE support
Common Escaping Methods:
```php
// Text content
= $escaper->escapeHtml($text) ?>
// HTML attributes
// JavaScript strings data-value="= $escaper->escapeJs($value) ?>" // URLs // CSS ``` If your widget needs interactivity, add JavaScript using Magento's widget pattern. File: ```javascript /** * Copyright Β© Vendor. All rights reserved. */ var config = { map: { '*': { widgetName: 'Vendor_ModuleName/js/widget-script' } } }; ``` File: ```javascript /** * Copyright Β© Vendor. All rights reserved. */ define([ 'jquery', 'jquery-ui-modules/widget' ], function ($) { 'use strict'; /** * Widget initialization pattern */ $.widget('vendor.widgetName', { options: { elementId: '', ajaxUrl: '' }, /** * Widget creation * @private */ _create: function () { this._bind(); }, /** * Bind event handlers * @private */ _bind: function () { var self = this; this.element.on('click', function (e) { e.preventDefault(); self._handleClick(); }); }, /** * Handle click event * @private */ _handleClick: function () { console.log('Widget clicked!'); console.log('Element ID:', this.options.elementId); // Example AJAX call if (this.options.ajaxUrl) { this._makeAjaxRequest(); } }, /** * Make AJAX request * @private */ _makeAjaxRequest: function () { var self = this; $.ajax({ url: this.options.ajaxUrl, type: 'POST', dataType: 'json', data: { // Your data here }, success: function (response) { self._handleResponse(response); }, error: function () { console.error('Request failed'); } }); }, /** * Handle AJAX response * @private */ _handleResponse: function (response) { if (response.success) { console.log('Success:', response.message); } else { console.error('Error:', response.message); } } }); return $.vendor.widgetName; }); ``` Initialization in Template: ```php data-mage-init='{"widgetName": { "elementId": "= $escaper->escapeJs($uniqueId) ?>", "ajaxUrl": "= $escaper->escapeUrl($block->getActionUrl()) ?>" }}'> Click Me ``` Alternative Initialization with ```php { "[data-bind]": { "Magento_Ui/js/core/app": { "components": { "widget-scope": { "component": "Vendor_ModuleName/js/widget-component" } } } } } ``` File: Load your CSS globally across all pages. ```xml xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd"> ``` File: ```css /** * Copyright Β© Vendor. All rights reserved. */ / Widget Container / .custom-widget { padding: 20px; margin: 10px 0; } .custom-widget .widget-content { background: #f5f5f5; padding: 15px; border-radius: 5px; } .custom-widget h3 { margin: 0 0 10px; font-size: 18px; font-weight: 600; } .custom-widget .widget-button { margin-top: 10px; } / Responsive Design / @media (max-width: 768px) { .custom-widget { padding: 15px; } .custom-widget .widget-content { padding: 10px; } } ``` CSS Best Practices: For widgets that need to process data (forms, AJAX requests), add a controller. File: ```xml xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd"> ``` Route URL Pattern: File: ```php /** * Copyright Β© Vendor. All rights reserved. */ declare(strict_types=1); namespace Vendor\ModuleName\Controller\Index; use Magento\Framework\App\Action\HttpPostActionInterface; use Magento\Framework\App\RequestInterface; use Magento\Framework\Controller\Result\JsonFactory; use Magento\Framework\Exception\LocalizedException; use Psr\Log\LoggerInterface; /** * Handle form submission */ class Submit implements HttpPostActionInterface { /** * @var RequestInterface */ private $request; /** * @var JsonFactory */ private $resultJsonFactory; /** * @var LoggerInterface */ private $logger; /** * @param RequestInterface $request * @param JsonFactory $resultJsonFactory * @param LoggerInterface $logger */ public function __construct( RequestInterface $request, JsonFactory $resultJsonFactory, LoggerInterface $logger ) { $this->request = $request; $this->resultJsonFactory = $resultJsonFactory; $this->logger = $logger; } /** * Execute action * * @return \Magento\Framework\Controller\Result\Json */ public function execute() { $resultJson = $this->resultJsonFactory->create(); if (!$this->request->isPost()) { return $resultJson->setData([ 'success' => false, 'message' => __('Invalid request method.') ]); } try { $postData = $this->request->getPostValue(); // Validate data $this->validateData($postData); // Process your data here // Example: Save to database, send email, etc. return $resultJson->setData([ 'success' => true, 'message' => __('Your request has been submitted successfully.') ]); } catch (LocalizedException $e) { $this->logger->error('Widget form error: ' . $e->getMessage()); return $resultJson->setData([ 'success' => false, 'message' => $e->getMessage() ]); } catch (\Exception $e) { $this->logger->error('Widget form error: ' . $e->getMessage()); return $resultJson->setData([ 'success' => false, 'message' => __('An error occurred. Please try again later.') ]); } } /** * Validate form data * * @param array $data * @throws LocalizedException */ private function validateData(array $data): void { if (empty($data['field_name'])) { throw new LocalizedException(__('Field name is required.')); } if (!filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { throw new LocalizedException(__('Please enter a valid email address.')); } } } ``` Controller Best Practices: For complex interactions like modal forms: Template with Modal: ```php $modalId = 'modal-' . uniqid(); ?> class="action primary" data-mage-init='{"widgetModal": {"modalId": "= $escaper->escapeJs($modalId) ?>"}}'> = $escaper->escapeHtml(__('Open Modal')) ?> class="widget-modal" style="display: none;" data-role="widget-modal"> × = $escaper->escapeHtml(__('Submit')) ?> ``` Modal JavaScript: IMPORTANT: ```javascript define([ 'jquery' ], function ($) { 'use strict'; $.widget('vendor.widgetModal', { options: { modalId: '' }, _create: function () { this._moveModalToBody(); this._bind(); }, /** * Move modal element to body to prevent parent container constraints * This is CRITICAL - without this, modal will be trapped inside widget container */ _moveModalToBody: function () { var modalElement = $('#' + this.options.modalId); if (modalElement.length && modalElement.parent()[0].tagName !== 'BODY') { // Move modal to body so it's not constrained by parent positioning modalElement.appendTo('body'); } }, _bind: function () { var self = this; // Open modal on button click this.element.on('click', function (e) { e.preventDefault(); self.openModal(); }); }, openModal: function () { var modalElement = $('#' + this.options.modalId); if (modalElement.length) { // Show modal with fade effect modalElement.fadeIn(300); $('body').addClass('modal-open'); // Bind close button (only once) modalElement.find('.widget-modal-close, .action.cancel').off('click').on('click', function (e) { e.preventDefault(); modalElement.fadeOut(300); $('body').removeClass('modal-open'); }); // Bind overlay click (only once) modalElement.find('.widget-modal-overlay').off('click').on('click', function (e) { e.preventDefault(); modalElement.fadeOut(300); $('body').removeClass('modal-open'); }); // Bind ESC key $(document).off('keyup.widgetModal').on('keyup.widgetModal', function (e) { if (e.key === 'Escape' || e.keyCode === 27) { modalElement.fadeOut(300); $('body').removeClass('modal-open'); $(document).off('keyup.widgetModal'); } }); } } }); return $.vendor.widgetModal; }); ``` Modal CSS Styling: ```css /** * Modal Styling * IMPORTANT: * - Use very high z-index (999999) with !important to ensure modal appears above all content * - Many themes use high z-index values for headers, menus, etc. (10000+) * - Modal container and all children use position: fixed to escape parent containers * - Overlay uses darker background (0.7 opacity) to clearly indicate blocked content */ .widget-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 999999 !important; } .widget-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); z-index: 999998 !important; cursor: pointer; } .widget-modal-content { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #fff; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); z-index: 999999 !important; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; } / Prevent body scroll when modal is open / body.modal-open { overflow: hidden; } / Ensure modal container blocks all pointer events to elements below / .widget-modal { pointer-events: auto; } .widget-modal * { pointer-events: auto; } .widget-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 30px; border-bottom: 1px solid #e0e0e0; } .widget-modal-header h2 { margin: 0; font-size: 24px; font-weight: 600; color: #333; } .widget-modal-close { background: none; border: none; font-size: 32px; line-height: 1; color: #666; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: color 0.3s ease; } .widget-modal-close:hover { color: #000; } .widget-modal-body { padding: 30px; } / Responsive Design / @media (max-width: 768px) { .widget-modal-content { width: 95%; max-height: 95vh; } .widget-modal-header { padding: 15px 20px; } .widget-modal-header h2 { font-size: 20px; } .widget-modal-body { padding: 20px; } } ``` ```bash # Enable the module bin/magento module:enable Vendor_ModuleName # Run setup upgrade bin/magento setup:upgrade # Compile dependency injection (production mode) bin/magento setup:di:compile # Deploy static content (production mode) bin/magento setup:static-content:deploy -f # Clear cache bin/magento cache:flush ``` Add widget code directly in CMS content: ```html {{widget type="Vendor\ModuleName\Block\Widget\WidgetName" text_param="My Value" select_param="value1" enabled="1"}} ``` Add widget programmatically in layout XML: ```xml name="custom.widget" template="Vendor_ModuleName::widget/template.phtml"> ``` Create widget block in any template: ```php = $block->getLayout() ->createBlock(\Vendor\ModuleName\Block\Widget\WidgetName::class) ->setData('text_param', 'My Value') ->setData('enabled', true) ->toHtml() ?> ``` Based on the Features: Key Files: ``` app/code/ItTools/QuoteForm/ βββ Block/Widget/QuoteButton.php βββ Controller/Index/Submit.php βββ etc/widget.xml βββ etc/email_templates.xml βββ view/frontend/templates/widget/quotebutton.phtml βββ view/frontend/web/js/quote-modal.js βββ view/frontend/web/js/quote-form.js βββ view/frontend/web/css/quote-form.css ``` Usage: ```html {{widget type="ItTools\QuoteForm\Block\Widget\QuoteButton" button_text="Get a Free Quote" button_class="action primary"}} ``` Symptoms: Widget doesn't show in "Insert Widget" dropdown Solutions: Symptoms: Widget code shows but no output Solutions: Symptoms: Widget functionality not working Solutions: Symptoms: Widget appears unstyled Solutions: Symptoms: AJAX returns errors or no response Solutions: Symptoms: Files not uploading or validation fails Solutions: Symptoms: Modal is half-hidden, trapped inside section/div, has z-index issues, double overlays, or positioning problems Root Causes: Solutions: Example Fix: ```javascript // WRONG - causes conflicts define(['jquery', 'Magento_Ui/js/modal/modal'], function ($, modal) { modalElement.modal({ ... }); }); // CORRECT - simple and works define(['jquery'], function ($) { modalElement.fadeIn(300); $('body').addClass('modal-open'); }); ``` Before deploying your widget: Real working examples from this codebase: - Modal popup widget - Form with file uploads - Email functionality - AJAX submission - Simple search widget - Extends existing functionality - Custom placeholder text parameter Use these as reference implementations. This skill is compatible with: Not compatible with:Step 6: Add JavaScript (Optional)
view/frontend/requirejs-config.jsview/frontend/web/js/widget-script.jsdata-bind (Knockout.js):Step 7: Add CSS Styling (Optional)
view/frontend/layout/default.xmlview/frontend/web/css/widget-style.css!important unless absolutely necessaryAdvanced: Adding Controllers for Form Submission
Step 1: Create Frontend Routes
etc/frontend/routes.xmlhttps://yourstore.com/modulename/index/submitmodulename = frontNameindex = controller directorysubmit = action file nameStep 2: Create Controller Action
Controller/Index/Submit.phpHttpPostActionInterface for POST requestsHttpGetActionInterface for GET requestsJsonFactory, PageFactory, RedirectFactory)Advanced: Modal Popup Widget
= $escaper->escapeHtml(__('Modal Title')) ?>
Magento_Ui/js/modal/modal component when you have custom modal HTML structure - it creates conflicts with z-index, positioning, and double overlays on initialization to prevent parent container constraints (overflow, positioning, z-index)Installation and Deployment
Installation Commands
Deployment Checklist
setup:upgrade)setup:di:compile)Using the Widget
Method 1: Admin Panel (CMS Editor)
Method 2: Direct Code in CMS Content
Method 3: XML Layout Files
Method 4: Programmatically in Template
Real-World Example: Quote Request Form Widget
ItTools_QuoteForm module created for LCD Screen Repair:Common Widget Use Cases
Best Practices Summary
Security
Performance
Code Quality
declare(strict_types=1);)User Experience
Maintainability
Troubleshooting
Widget Not Appearing in Admin
widget.xml syntax (validate XML)bin/magento module:statusbin/magento cache:flushrm -rf generated/code/*system.log for errorsWidget Not Rendering on Frontend
bin/magento cache:flushexception.log and system.logJavaScript Not Loading
requirejs-config.js syntaxbin/magento setup:static-content:deploy -fCSS Styles Not Applied
layout/default.xmlbin/magento setup:static-content:deploy -fForm Submission Failing
exception.log for server errorsFile Upload Issues
upload_max_filesize and post_max_sizeenctype="multipart/form-data"$_FILES array in controllerModal Popup Issues
Magento_Ui/js/modal/modal component and custom modal HTML structuremodalElement.appendTo('body') in widget initialization to escape parent containersfadeIn()/fadeOut() instead of modal('openModal')position: fixed on BOTH overlay and content (not absolute)rgba(0, 0, 0, 0.7) to clearly block contentbody.modal-open { overflow: hidden; } to prevent background scrollpointer-events: auto to modal and children to block clicksrm -rf pub/static/frontend/*Testing Checklist
Functional Testing
Browser/Device Testing
Performance Testing
Security Testing
Accessibility Testing
Reference: ItTools Module Examples
app/code/ItTools/QuoteForm/)app/code/ItTools/CategorySearch/)Additional Resources
Magento DevDocs
Code Examples
vendor/magento/module-widget/vendor/magento/module-cms/Block/Widget/vendor/magento/module-catalog/Block/Widget/Version Compatibility
More from this repository3
Generates frontend controller actions in Magento 2, enabling custom storefront pages, AJAX endpoints, form submissions, and JavaScript-driven API-like interactions.
Guides Magento 2 developers in customizing transactional email themes, styling, and CSS inlining across different email templates and frameworks.
Monitors multiple service status pages using curl and jq, instantly detecting and alerting when monitors go down across providers like UptimeRobot and PayPal.