Short answer: extend \Elementor\Widget_Base, register your controls in
register_controls(), output markup in render(), and register the class
on the elementor/widgets/register hook. Roughly 80 lines gets you a
widget that behaves like a built-in one.
The reason to bother is worth stating first.
Why not just use an HTML block?
Every Elementor build eventually meets a section the built-in widgets cannot make. There are three ways out, and two of them are bad.
Paste it into an HTML widget. Fastest, and it works. But the content is now invisible to the page builder. The client cannot change a heading without opening code — which defeats the entire reason you chose WordPress for them.
Stack three widgets and force them into shape with negative margins and custom CSS. It looks right until someone edits it, then it collapses. You will be fixing it repeatedly.
Write a widget. More work today, far less later, and the client keeps the editing experience they were promised.
The minimum viable widget
Create elementor-widgets/class-feature-box.php in your theme or a small
plugin:
<?php
if ( ! defined( 'ABSPATH' ) ) exit; // never let the file run directly
class Feature_Box_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'feature_box'; // unique, internal
}
public function get_title() {
return esc_html__( 'Feature Box', 'textdomain' );
}
public function get_icon() {
return 'eicon-info-box'; // any Elementor icon class
}
public function get_categories() {
return [ 'general' ]; // which editor panel it appears in
}
So far this is just identity — name, label, icon, panel. Now the controls the client will actually see:
protected function register_controls() {
$this->start_controls_section( 'content_section', [
'label' => esc_html__( 'Content', 'textdomain' ),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
] );
$this->add_control( 'heading', [
'label' => esc_html__( 'Heading', 'textdomain' ),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => esc_html__( 'Your heading', 'textdomain' ),
] );
$this->add_control( 'body', [
'label' => esc_html__( 'Body text', 'textdomain' ),
'type' => \Elementor\Controls_Manager::TEXTAREA,
'rows' => 4,
] );
$this->add_control( 'icon', [
'label' => esc_html__( 'Icon', 'textdomain' ),
'type' => \Elementor\Controls_Manager::ICONS,
] );
$this->end_controls_section();
}
Then the output:
protected function render() {
$settings = $this->get_settings_for_display();
?>
<div class="feature-box">
<?php if ( ! empty( $settings['icon'] ) ) : ?>
<span class="feature-box__icon">
<?php \Elementor\Icons_Manager::render_icon( $settings['icon'], [ 'aria-hidden' => 'true' ] ); ?>
</span>
<?php endif; ?>
<h3 class="feature-box__title"><?php echo esc_html( $settings['heading'] ); ?></h3>
<p class="feature-box__body"><?php echo esc_html( $settings['body'] ); ?></p>
</div>
<?php
}
}
Note esc_html() on everything coming from settings. Elementor does not
escape output for you, and a widget is a place client-entered content
reaches the page directly.
Registering it
In your theme’s functions.php or plugin bootstrap:
add_action( 'elementor/widgets/register', function( $widgets_manager ) {
require_once __DIR__ . '/elementor-widgets/class-feature-box.php';
$widgets_manager->register( new \Feature_Box_Widget() );
} );
That hook name matters. elementor/widgets/widgets_registered with
register_widget_type() is the pre-3.5 form — it still appears in most
tutorials online and is deprecated.
Two things worth adding
Style controls, not just content. If the client can change the text but
not the colour, you will get a message asking you to change the colour.
Add a TAB_STYLE section with a COLOR control and bind it with
selectors so Elementor generates the CSS itself:
$this->add_control( 'title_color', [
'label' => esc_html__( 'Title colour', 'textdomain' ),
'type' => \Elementor\Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .feature-box__title' => 'color: {{VALUE}};',
],
] );
A live preview. Without content_template(), editing shows a full page
reload for every keystroke change. It duplicates render() in JavaScript
templating, which is tedious — but on a widget the client will use daily,
it is worth it.
When it is not worth it
If the section appears once, will never change, and the client has no reason to touch it, an HTML block is a reasonable decision. The test I use is simple: will anyone need to edit this after launch? If yes, write the widget. If genuinely no, do not over-engineer it.
Need a section Elementor cannot build? Tell me what you are trying to make — I have written custom widgets for client sites where keeping the page builder usable mattered more than shipping quickly.