There is a point on most Elementor projects where the existing widgets stop being enough. You need a card that pulls three custom fields and formats them a particular way, or a component the client will drop onto forty pages and must not be able to break. Rebuilding it by hand each time is a maintenance problem waiting to happen.

A custom widget solves that. It gives the client a proper panel of controls, the styling lives in your code rather than in whatever the last person clicked, and the markup is yours.

By the end of this you will have a working widget registered from a small plugin, with a content tab, a style tab, escaped output, and its own category in the widget panel. I have used a testimonial card as the example because it exercises most of the control types you will actually reach for.

Before you start

You need FTP or SFTP access, or a local development environment, plus Elementor installed and active. Everything here works with the free version, and the code is written for PHP 8.x and current WordPress.

Build this as its own plugin, not in your theme’s functions.php. A theme update wipes functions.php, and a widget that vanishes takes every page using it down with it. A plugin also means you can deactivate it cleanly if something goes wrong. This is genuinely the right shape for the job, and it is the same structure I use for client plugin development work.

Work on staging or local first. A PHP fatal error in a plugin that hooks Elementor will take out the editor as well as the front end.

Step 1: Decide whether you actually need a widget

Worth answering before you write anything, because a lot of custom widgets should not exist.

Use an existing widget with dynamic tags when the layout is standard and only the content varies. If you want a heading that shows an ACF field, that is a Heading widget with a dynamic tag on it. No code. I wrote about pushing that approach further in expanding Elementor’s dynamic tags limit.

Build a custom widget when the markup itself is non standard (a component with a specific structure, schema markup or accessibility requirements), when the same block appears across many pages and must stay consistent, when it needs to query something Elementor cannot reach, or when you want to limit what an editor can change.

If a stack of six nested containers with a dynamic tag would do the job, use that. Code you write is code you maintain.

Step 2: Create the plugin scaffold

Create wp-content/plugins/abc-elementor-widgets/abc-elementor-widgets.php:

<?php
/**
 * Plugin Name: Custom Elementor Widgets
 * Description: Bespoke Elementor widgets for this site.
 * Version:     1.0.0
 * Author:      Your Name
 * Text Domain: abc-elementor
 */

// Stop the file being executed by a direct HTTP request.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'ABC_ELEMENTOR_PATH', plugin_dir_path( __FILE__ ) );
define( 'ABC_ELEMENTOR_URL', plugin_dir_url( __FILE__ ) );

Use a prefix of your own throughout so nothing collides with another plugin. I have used abc here.

Step 3: Check Elementor is active before doing anything

This is the step people skip, and it is why some sites white screen the moment Elementor is deactivated for troubleshooting. Your widget class extends an Elementor class. If that class does not exist, PHP throws a fatal error and takes the whole site with it.

Guard everything behind a check, and tell the admin why rather than failing silently:

add_action( 'plugins_loaded', function () {

    // did_action() is the reliable signal that Elementor has finished
    // loading. Checking the class alone can be true too early.
    if ( ! did_action( 'elementor/loaded' ) ) {
        add_action( 'admin_notices', function () {
            printf(
                '<div class="notice notice-warning"><p>%s</p></div>',
                esc_html__(
                    'Custom Elementor Widgets requires Elementor to be installed and active.',
                    'abc-elementor'
                )
            );
        } );
        return; // Nothing else in this plugin runs.
    }

    require_once ABC_ELEMENTOR_PATH . 'includes/class-testimonial-widget.php';

    // Registration hook as of Elementor 3.5. The older
    // elementor/widgets/widgets_registered hook is deprecated.
    add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
        $widgets_manager->register( new \ABC_Testimonial_Widget() );
    } );
} );

Two things to note. elementor/widgets/register replaced the old widgets_registered hook, and the manager method is register() rather than register_widget_type(). Code you find in older tutorials will still work for now because Elementor keeps deprecated aliases, but it will produce deprecation notices and eventually stop.

Step 4: Add a custom widget category

By default your widget lands in “General”, mixed in with everything else. Giving it its own category makes it findable, which matters more than it sounds when a client is hunting for the component you built them.

Add this inside the same plugins_loaded callback, before the widget registration:

add_action( 'elementor/elements/categories_registered', function ( $elements_manager ) {
    $elements_manager->add_category(
        'abc-widgets', // slug, referenced by get_categories() later
        [
            'title' => esc_html__( 'Site Widgets', 'abc-elementor' ),
            'icon'  => 'fa fa-plug', // shown in the panel
        ]
    );
} );

Step 5: Build the widget class

Create includes/class-testimonial-widget.php. Every widget extends \Elementor\Widget_Base and implements a small set of methods that tell Elementor how to present it.

<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class ABC_Testimonial_Widget extends \Elementor\Widget_Base {

    /**
     * Internal identifier. Saved into the page data, so changing it later
     * orphans every existing instance of the widget. Choose once.
     */
    public function get_name() {
        return 'abc_testimonial';
    }

    // The label shown in the widget panel.
    public function get_title() {
        return esc_html__( 'Testimonial Card', 'abc-elementor' );
    }

    // Any Elementor or Font Awesome icon class.
    public function get_icon() {
        return 'eicon-testimonial';
    }

    // Must match the slug registered in step 4.
    public function get_categories() {
        return [ 'abc-widgets' ];
    }

    // Extra search terms for the panel's search box.
    public function get_keywords() {
        return [ 'testimonial', 'review', 'quote', 'card' ];
    }
}

get_name() deserves the warning. It is the key stored in the page’s saved data. Rename it after the client has used the widget on thirty pages and all thirty become unrecognised elements.

Step 6: Register the content controls

Controls are the fields in the left hand panel. They go inside register_controls(), grouped into sections you open with start_controls_section() and close with end_controls_section().

protected function register_controls() {

    $this->start_controls_section(
        'content_section',
        [
            'label' => esc_html__( 'Content', 'abc-elementor' ),
            'tab'   => \Elementor\Controls_Manager::TAB_CONTENT,
        ]
    );

    $this->add_control(
        'quote',
        [
            'label'   => esc_html__( 'Quote', 'abc-elementor' ),
            'type'    => \Elementor\Controls_Manager::TEXTAREA,
            'rows'    => 5,
            'default' => esc_html__( 'A short quote goes here.', 'abc-elementor' ),
        ]
    );

    $this->add_control(
        'author_name',
        [
            'label'       => esc_html__( 'Author name', 'abc-elementor' ),
            'type'        => \Elementor\Controls_Manager::TEXT,
            'placeholder' => esc_html__( 'Jane Smith', 'abc-elementor' ),
            // Lets the editor pull this from a custom field instead.
            'dynamic'     => [ 'active' => true ],
        ]
    );

    $this->add_control(
        'author_photo',
        [
            'label'   => esc_html__( 'Author photo', 'abc-elementor' ),
            'type'    => \Elementor\Controls_Manager::MEDIA,
            'default' => [
                'url' => \Elementor\Utils::get_placeholder_image_src(),
            ],
        ]
    );

    $this->add_control(
        'show_rating',
        [
            'label'        => esc_html__( 'Show star rating', 'abc-elementor' ),
            'type'         => \Elementor\Controls_Manager::SWITCHER,
            'return_value' => 'yes',
            'default'      => 'yes',
        ]
    );

    $this->add_control(
        'rating',
        [
            'label'   => esc_html__( 'Rating', 'abc-elementor' ),
            'type'    => \Elementor\Controls_Manager::SELECT,
            'options' => [
                '5' => '5 stars',
                '4' => '4 stars',
                '3' => '3 stars',
            ],
            'default'   => '5',
            // Only shown when the switcher above is on.
            'condition' => [ 'show_rating' => 'yes' ],
        ]
    );

    $this->end_controls_section();
}

The control types worth knowing are TEXT and TEXTAREA for copy, MEDIA for images, SELECT for a fixed list, SWITCHER for a yes/no toggle, URL for links (it returns the address plus the external and nofollow flags), NUMBER, CHOOSE for icon style pickers, and REPEATER for repeating rows.

condition is the detail that separates a widget that feels considered from one that does not. Hiding the rating dropdown until the toggle is on keeps the panel short.

Step 7: Add a repeater

A repeater lets the editor add rows. Each row gets its own set of controls, defined on a separate Repeater object:

$repeater = new \Elementor\Repeater();

$repeater->add_control(
    'feature_text',
    [
        'label'   => esc_html__( 'Feature', 'abc-elementor' ),
        'type'    => \Elementor\Controls_Manager::TEXT,
        'default' => esc_html__( 'Point of praise', 'abc-elementor' ),
    ]
);

$this->add_control(
    'features',
    [
        'label'   => esc_html__( 'Highlights', 'abc-elementor' ),
        'type'    => \Elementor\Controls_Manager::REPEATER,
        'fields'  => $repeater->get_controls(),
        // Which row field labels each collapsed row in the panel.
        // Get this wrong and every row reads "Item #1", "Item #2".
        'title_field' => '{{{ feature_text }}}',
    ]
);

Repeater values come back as an array of associative arrays, one per row, keyed by the control names you defined.

Step 8: Add a style section

Style controls belong in their own section on the Style tab. The selectors key is what makes them work: Elementor writes the CSS for you, with {{WRAPPER}} standing in for the unique wrapper class of that widget instance, so styling one card never touches another.

$this->start_controls_section(
    'style_section',
    [
        'label' => esc_html__( 'Card', 'abc-elementor' ),
        'tab'   => \Elementor\Controls_Manager::TAB_STYLE,
    ]
);

$this->add_control(
    'quote_colour',
    [
        'label'     => esc_html__( 'Quote colour', 'abc-elementor' ),
        'type'      => \Elementor\Controls_Manager::COLOR,
        'selectors' => [
            // {{WRAPPER}} scopes the rule to this widget instance only.
            '{{WRAPPER}} .abc-testimonial__quote' => 'color: {{VALUE}};',
        ],
    ]
);

// Group controls add a whole set of related fields in one call.
$this->add_group_control(
    \Elementor\Group_Control_Typography::get_type(),
    [
        'name'     => 'quote_typography',
        'selector' => '{{WRAPPER}} .abc-testimonial__quote',
    ]
);

$this->add_responsive_control(
    'card_padding',
    [
        'label'      => esc_html__( 'Padding', 'abc-elementor' ),
        'type'       => \Elementor\Controls_Manager::DIMENSIONS,
        'size_units' => [ 'px', 'em', '%' ],
        'selectors'  => [
            '{{WRAPPER}} .abc-testimonial' =>
                'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
        ],
    ]
);

$this->end_controls_section();

Use add_responsive_control() rather than add_control() for anything spatial, since it gives the editor separate desktop, tablet and mobile values automatically. Group controls exist for typography, border, box shadow, background and text shadow, and each one saves you defining eight or ten fields by hand.

Step 9: Render the output safely

render() produces the front end HTML. Get the saved values with get_settings_for_display(), which is the method that resolves dynamic tags, unlike get_settings().

Everything you echo must be escaped. A widget is a place where whatever an editor typed goes straight into your page, so this is not optional:

protected function render() {
    $settings = $this->get_settings_for_display();

    // Escape by context: esc_html for text, esc_url for URLs,
    // esc_attr for attribute values.
    ?>
    <div class="abc-testimonial">

        <?php if ( ! empty( $settings['author_photo']['url'] ) ) : ?>
            <img class="abc-testimonial__photo"
                 src="<?php echo esc_url( $settings['author_photo']['url'] ); ?>"
                 alt="<?php echo esc_attr( $settings['author_name'] ); ?>"
                 loading="lazy">
        <?php endif; ?>

        <blockquote class="abc-testimonial__quote">
            <?php echo esc_html( $settings['quote'] ); ?>
        </blockquote>

        <?php if ( 'yes' === $settings['show_rating'] ) : ?>
            <p class="abc-testimonial__rating">
                <?php
                printf(
                    /* translators: %s: number of stars */
                    esc_html__( 'Rated %s out of 5', 'abc-elementor' ),
                    esc_html( $settings['rating'] )
                );
                ?>
            </p>
        <?php endif; ?>

        <?php if ( ! empty( $settings['features'] ) ) : ?>
            <ul class="abc-testimonial__features">
                <?php foreach ( $settings['features'] as $item ) : ?>
                    <li><?php echo esc_html( $item['feature_text'] ); ?></li>
                <?php endforeach; ?>
            </ul>
        <?php endif; ?>

        <cite class="abc-testimonial__author">
            <?php echo esc_html( $settings['author_name'] ); ?>
        </cite>
    </div>
    <?php
}

If a control is meant to accept limited HTML, for example a quote containing a link, use wp_kses_post() rather than dropping the escaping. Never echo a raw setting.

Step 10: Load styles and scripts

Do not inline a <style> block in render(), because it repeats for every instance on the page. Register your assets and declare them as widget dependencies, so Elementor only loads them on pages where the widget appears:

// In the plugins_loaded callback, alongside registration.
add_action( 'wp_enqueue_scripts', function () {
    wp_register_style(
        'abc-testimonial',
        ABC_ELEMENTOR_URL . 'assets/testimonial.css',
        [],
        '1.0.0'
    );
} );

Then declare it on the class:

/**
 * Elementor enqueues these only when the widget is on the page.
 * The handles must already be registered, or nothing loads.
 */
public function get_style_depends() {
    return [ 'abc-testimonial' ];
}

public function get_script_depends() {
    return [];
}

Bump the version string when you change the file, otherwise browsers keep serving the cached copy and you will spend twenty minutes convinced your CSS is wrong.

How to check it worked

Activate the plugin, open any page in Elementor, and search the widget panel for “Testimonial”. It should appear under your Site Widgets category with the icon you chose.

Drop it on the page and work through the panel. The switcher should hide and show the rating dropdown. Colour and typography changes should apply live in the editor without a reload, which confirms your selectors are matching. Add two repeater rows and check each collapsed row shows its own text rather than “Item #1”.

Then publish and view the front end. Open the page source and confirm your CSS file is loaded, and confirm it is absent on a page without the widget. That proves get_style_depends() is doing its job.

When it does not work

The widget does not appear in the panel

Usually the category slug in get_categories() does not match the one you registered, so the widget exists but is filed under a category that does not. Temporarily return [ 'general' ] to confirm. Otherwise check that your require_once path is right and that registration is inside the elementor/loaded guard.

Fatal error, or the editor will not load

Almost always a class name typo, a missing backslash on an Elementor class (they are namespaced, so \Elementor\Controls_Manager needs the leading slash as soon as your own file declares a namespace), or a syntax error. Deactivate the plugin by renaming its folder over SFTP to get back in, then check your error log.

Style controls do nothing

The selector does not match your rendered markup. Inspect the element on the front end and compare the class names character by character. This is why a strict naming convention on your markup helps.

Dynamic tags are not available on a control

You have not added 'dynamic' => [ 'active' => true ] to it. Note that dynamic tags are an Elementor Pro feature, so the option appears only where Pro is active.

Changes to the widget do not show on existing pages

Elementor caches generated CSS per page. Go to Elementor, Tools, and click Clear Files & Data in the Elementor Cache section. Elementor 3.27 and earlier labelled the same control Regenerate CSS & Data. If the markup itself looks stale, clear any page cache and CDN cache too.

Common questions

Do I need Elementor Pro to build custom widgets?

No. The widget API is in the free plugin. Pro adds features your widget can integrate with, such as dynamic tags and theme builder locations, but building and registering widgets does not require it.

Can I put the widget code in my theme?

You can, and I would not. A theme update wipes functions.php, and moving to a new theme takes your widgets with it, breaking every page that used them. A small plugin is a few extra minutes and avoids both.

How do I make the widget preview live in the editor?

Implement content_template() alongside render(), writing the same markup in Elementor’s JavaScript template syntax with {{{ settings.field }}}. It is optional. Without it the editor falls back to the server rendered output, which is slower to refresh but perfectly usable, and it means one less copy of your markup to keep in sync.

Can one plugin hold several widgets?

Yes, and that is the usual pattern. Add a class file per widget and register each one inside the same elementor/widgets/register callback.

Will my widget survive Elementor updates?

The widget API has been stable for a long time. What breaks are deprecated hooks, so use elementor/widgets/register rather than the older names, and read the release notes if you maintain widgets across several client sites.

Taking it further

You now have the shape that every Elementor widget follows: a guarded plugin that only loads when Elementor is present, a class extending Widget_Base with its identity methods, controls split across content and style tabs, escaped rendering, and assets that load only where the widget is used. Everything else is variation on those parts.

The habits that pay off later are the boring ones. Pick get_name() carefully and never change it. Escape every value on the way out. Put spatial controls behind add_responsive_control(). Keep the class names in your markup and your selectors in step. And build in a plugin, so your work survives the next theme change.

If you would rather have a bespoke widget or a full plugin built and handed over documented, that is what my WordPress plugin development work covers.