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 Pluginsheader) - Elementor 3.5 or newer (for the
elementor/widgets/registerhook) - 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()andget_icon()control how it appears in the editor panel. The icon uses Elementor'seicon-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 thanget_settings(). - Escape everything you print.
esc_html()covers text.add_link_attributes()safely builds thehref,target, andrelattributes 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()withadd_responsive_control()for spacing and alignment, so editors can set values per breakpoint. - Typography: add
Group_Control_Typographyto give editors the full font panel for the title. - Repeaters: use the
REPEATERcontrol 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.