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.

ElementorWordPressPHPWidgets
Written by

Amir Iqbal

Full-Stack Web & Mobile Developer. I build websites, web apps and mobile apps — and finish or fix the ones that got stuck. Around 1,500 completed projects with a 5.0 rating.

More about how I work