How to Create a Custom Elementor Widget (Full Code Guide)

Create a custom Elementor widget step by step as a small WordPress plugin, with controls, safe output, ACF fields, and how to extend an existing widget instead.

Elementor's built-in widgets cover most layouts, but every growing website eventually has a pattern that doesn't fit: a branded feature card, a pricing row with business rules, or content pulled from an API. Rebuilding that pattern by hand on every page is slow and leads to inconsistent results. A custom widget turns it into one reusable, editor-friendly component.

This guide shows how to create a custom Elementor widget from scratch: a complete Feature Card widget, packaged as its own plugin. It has a title, description, optional link, and style controls, and it loads its CSS only on pages that use it.

What you need

  • WordPress 6.5 or newer (for the Requires Plugins header)
  • Elementor 3.5 or newer (for the elementor/widgets/register hook)
  • Basic PHP and a local WordPress install for testing

Put the widget in its own plugin rather than in your theme. Widgets are functionality, so they should survive a theme change.

Step 1: Create the plugin

Create a folder called feature-card-widget in wp-content/plugins/ with this structure:

feature-card-widget/
├── feature-card-widget.php
├── widgets/
│   └── class-feature-card-widget.php
└── assets/
    └── feature-card.css

The main plugin file registers the widget and its stylesheet:

<?php
/**
 * Plugin Name: Feature Card Widget
 * Description: A custom Elementor widget for feature cards.
 * Version: 1.0.0
 * Requires Plugins: elementor
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

add_action( 'elementor/widgets/register', function ( $widgets_manager ) {
	require_once __DIR__ . '/widgets/class-feature-card-widget.php';
	$widgets_manager->register( new \Feature_Card_Widget() );
} );

add_action( 'wp_enqueue_scripts', function () {
	wp_register_style(
		'feature-card-widget',
		plugins_url( 'assets/feature-card.css', __FILE__ ),
		[],
		'1.0.0'
	);
} );

Two details matter here. The elementor/widgets/register hook only fires when Elementor is active, so the widget class is never loaded without it. The stylesheet is only registered, not enqueued. The widget will ask for it in Step 3, so pages without the widget don't download it.

Step 2: Write the widget class

Every Elementor widget extends \Elementor\Widget_Base and describes itself through a few methods. Create widgets/class-feature-card-widget.php:

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

use Elementor\Controls_Manager;
use Elementor\Widget_Base;

class Feature_Card_Widget extends Widget_Base {

	public function get_name() {
		return 'feature_card';
	}

	public function get_title() {
		return esc_html__( 'Feature Card', 'feature-card-widget' );
	}

	public function get_icon() {
		return 'eicon-info-box';
	}

	public function get_categories() {
		return [ 'general' ];
	}

	public function get_keywords() {
		return [ 'feature', 'card', 'service' ];
	}
}
  • get_name() is the widget's permanent ID. Changing it later breaks pages that already use the widget.
  • get_title() and get_icon() control how it appears in the editor panel. The icon uses Elementor's eicon- icon font.
  • get_keywords() helps editors find the widget through the panel search.

Step 3: Add controls

Controls are the fields editors see in the panel. Add these methods inside the class:

public function get_style_depends() {
	return [ 'feature-card-widget' ];
}

protected function register_controls() {
	$this->start_controls_section(
		'content_section',
		[
			'label' => esc_html__( 'Content', 'feature-card-widget' ),
			'tab'   => Controls_Manager::TAB_CONTENT,
		]
	);

	$this->add_control(
		'title',
		[
			'label'       => esc_html__( 'Title', 'feature-card-widget' ),
			'type'        => Controls_Manager::TEXT,
			'default'     => esc_html__( 'Fast delivery', 'feature-card-widget' ),
			'label_block' => true,
		]
	);

	$this->add_control(
		'description',
		[
			'label'   => esc_html__( 'Description', 'feature-card-widget' ),
			'type'    => Controls_Manager::TEXTAREA,
			'default' => esc_html__( 'Explain the benefit in one or two sentences.', 'feature-card-widget' ),
		]
	);

	$this->add_control(
		'link',
		[
			'label' => esc_html__( 'Link', 'feature-card-widget' ),
			'type'  => Controls_Manager::URL,
		]
	);

	$this->end_controls_section();

	$this->start_controls_section(
		'style_section',
		[
			'label' => esc_html__( 'Card', 'feature-card-widget' ),
			'tab'   => Controls_Manager::TAB_STYLE,
		]
	);

	$this->add_control(
		'background_color',
		[
			'label'     => esc_html__( 'Background', 'feature-card-widget' ),
			'type'      => Controls_Manager::COLOR,
			'selectors' => [
				'{{WRAPPER}} .feature-card' => 'background-color: {{VALUE}};',
			],
		]
	);

	$this->add_control(
		'title_color',
		[
			'label'     => esc_html__( 'Title color', 'feature-card-widget' ),
			'type'      => Controls_Manager::COLOR,
			'selectors' => [
				'{{WRAPPER}} .feature-card__title' => 'color: {{VALUE}};',
			],
		]
	);

	$this->end_controls_section();
}

get_style_depends() tells Elementor to enqueue the stylesheet registered in Step 1, but only on pages where this widget appears.

The selectors key is the part most tutorials skip. Instead of printing inline styles in your HTML, Elementor generates CSS scoped to this widget instance ({{WRAPPER}}). Styles stay consistent, and editors see changes instantly in the preview.

Step 4: Render the output

The render() method prints the widget's HTML on the front end:

protected function render() {
	$settings = $this->get_settings_for_display();
	$has_link = ! empty( $settings['link']['url'] );

	if ( $has_link ) {
		$this->add_link_attributes( 'card_link', $settings['link'] );
	}
	?>
	<div class="feature-card">
		<h3 class="feature-card__title"><?php echo esc_html( $settings['title'] ); ?></h3>
		<p class="feature-card__text"><?php echo esc_html( $settings['description'] ); ?></p>
		<?php if ( $has_link ) : ?>
			<a class="feature-card__link" <?php $this->print_render_attribute_string( 'card_link' ); ?>>
				<?php esc_html_e( 'Learn more', 'feature-card-widget' ); ?>
			</a>
		<?php endif; ?>
	</div>
	<?php
}
  • Always read settings with get_settings_for_display(), which resolves dynamic tags, rather than get_settings().
  • Escape everything you print. esc_html() covers text. add_link_attributes() safely builds the href, target, and rel attributes from the URL control, including the "open in new window" and "nofollow" options.

Step 5: Add the stylesheet

Keep the base styles small, so the style controls can override them:

.feature-card {
	padding: 32px;
	border: 1px solid rgba(0, 0, 0, 0.1);
	border-radius: 8px;
	background: #fff;
}

.feature-card__title {
	margin: 0 0 12px;
	font-size: 1.25rem;
}

.feature-card__text {
	margin: 0 0 20px;
}

.feature-card__link {
	font-weight: 600;
	text-decoration: underline;
}

Activate the plugin, open any page in Elementor, and search the panel for "Feature Card".

Common mistakes to avoid

Mistake Why it hurts Do this instead
Using _register_controls() Deprecated since Elementor 3.1 Use register_controls()
Hooking elementor/widgets/widgets_registered Deprecated since Elementor 3.5 Use elementor/widgets/register
Enqueuing CSS on every page Slows down pages that don't use the widget Register the file and return it from get_style_depends()
Printing settings without escaping Opens the door to XSS through editor input Escape with esc_html(), esc_attr(), and esc_url()
Hard-coding colors in CSS Editors can't adjust the design Expose style controls with selectors
Putting widgets in the theme Widgets disappear when the theme changes Ship widgets as a plugin

Show ACF fields in a custom widget

If your content lives in Advanced Custom Fields, a widget can read it directly. For example, to show a "specifications" field from the current post inside render():

$specs = function_exists( 'get_field' ) ? get_field( 'specifications', get_the_ID() ) : '';

if ( $specs ) {
	echo '<div class="feature-card__specs">' . wp_kses_post( $specs ) . '</div>';
}

The function_exists() check keeps the widget working if ACF is ever deactivated, and wp_kses_post() allows safe HTML from a rich-text field. If you have Elementor Pro, dynamic tags can connect ACF fields to built-in widgets without code. A custom widget is the better choice when the layout or logic is specific to your site.

Extend an existing Elementor widget instead

Sometimes you don't need a new widget at all, just one more option on a built-in one. Elementor provides hooks for both the controls and the output.

To add a control to an existing widget, hook into the end of one of its control sections. This adds a "Badge text" field to the Heading widget's content section:

add_action( 'elementor/element/heading/section_title/before_section_end', function ( $element ) {
	$element->add_control(
		'badge_text',
		[
			'label' => esc_html__( 'Badge text', 'feature-card-widget' ),
			'type'  => \Elementor\Controls_Manager::TEXT,
		]
	);
} );

To change what a widget outputs, filter its rendered HTML:

add_filter( 'elementor/widget/render_content', function ( $content, $widget ) {
	if ( 'heading' !== $widget->get_name() ) {
		return $content;
	}

	$badge = $widget->get_settings_for_display( 'badge_text' );

	if ( $badge ) {
		$content .= '<span class="heading-badge">' . esc_html( $badge ) . '</span>';
	}

	return $content;
}, 10, 2 );

The hook name follows the pattern elementor/element/{widget name}/{section ID}/before_section_end. You can find a widget's name and section IDs in Elementor's source code for that widget.

Extending is ideal for small additions. Once you find yourself rewriting most of a widget's output, a custom widget is cleaner and easier to maintain.

Taking it further

Once the basics work, these are the usual next steps:

  • Responsive controls: replace add_control() with add_responsive_control() for spacing and alignment, so editors can set values per breakpoint.
  • Typography: add Group_Control_Typography to give editors the full font panel for the title.
  • Repeaters: use the REPEATER control for lists such as feature bullets or pricing rows.
  • Dynamic data: pull content from custom fields or an external API and cache it with a transient.
  • A widget collection: group several related widgets under a custom category with a shared settings page. At that point you're building an Elementor addon.

Frequently asked questions

Can I create a custom Elementor widget without coding?

Not a true widget. Elementor has no visual widget builder. Without code, the closest options are saved templates and global widgets, which reuse a design you've already built. A custom widget with its own controls requires PHP.

Do custom widgets work with the free version of Elementor?

Yes. Widget_Base, the controls, and the registration hook are all part of free Elementor. Elementor Pro isn't required unless your widget uses Pro features such as dynamic tags.

Where should custom widget code go?

In a small plugin, as shown above. Putting it in functions.php ties the widget to your theme, so it disappears if the theme changes.

What's the difference between a custom element and a custom widget?

In Elementor, "elements" include containers, sections, columns, and widgets. When people talk about creating a custom Elementor element, they almost always mean a custom widget, which is what this guide builds.

When to build an addon instead

One widget fits nicely in a small plugin. When a team needs a set of branded components across several websites, the work changes: shared foundations, versioning, update delivery, documentation, and compatibility testing with each Elementor release. That's addon territory, and it's worth planning that way from the start rather than letting single widgets pile up.

Have a project in mind? Let’s talk.

Tell me what you are building and where you need support. I’ll respond with a clear, practical next step.

Start your project