Sometimes Elementor almost does what you need.
You have a field in Advanced Custom Fields, the data is already attached to the post, and all you want is a small widget that prints it in the right place.
You could install another Elementor addon pack containing 40 widgets you will never use.
Or you can write one.
This example creates a custom Elementor widget called Product Feature Notes. It reads an ACF field named featured_items and outputs the values as a list.
The original use was for WooCommerce product data, but there is nothing particularly WooCommerce-specific about the approach. Change the field name and markup and the same pattern works for posts, pages, products, or any other post type.
The ACF field
The widget expects an ACF field with the field name:
featured_items
The original code assumes that get_field( 'featured_items' ) returns an array.
That would make sense for something like:
- a Checkbox field
- a multi-select field
- another ACF field type returning multiple values
For example, a product might have:
Free shipping
Made in the USA
Two-year warranty
Ships within 24 hours
The widget turns those values into an unordered list.
Registering a custom Elementor widget
Elementor widgets extend Elementor’s Widget_Base class.
The important detail is that the class cannot be safely defined until Elementor itself has loaded. That is why the widget registration is hooked into Elementor rather than being instantiated immediately from functions.php.
The basic structure looks like this:
function register_product_feature_notes_widget() {
if (
defined( 'ELEMENTOR_PATH' ) &&
class_exists( '\Elementor\Widget_Base' )
) {
class Elementor_Product_Feature_Notes_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'product_feature_notes_widget';
}
public function get_title() {
return __( 'Product Feature Notes', 'your-text-domain' );
}
public function get_icon() {
return 'eicon-note';
}
public function get_categories() {
return [ 'general' ];
}
protected function render() {
if (
function_exists( 'get_field' ) &&
get_field( 'featured_items' )
) {
$featured_items = get_field( 'featured_items' );
echo '<h2>Product Feature Notes</h2>';
echo '<ul>';
foreach ( $featured_items as $featured_item ) {
echo '<li>' . esc_html( $featured_item ) . '</li>';
}
echo '</ul>';
} else {
echo '<p>No featured items or notes found.</p>';
}
}
}
\Elementor\Plugin::instance()
->widgets_manager
->register(
new Elementor_Product_Feature_Notes_Widget()
);
}
}
add_action(
'elementor/widgets/widgets_registered',
'register_product_feature_notes_widget'
);
Once registered, Product Feature Notes appears as a widget inside Elementor.
Drop it into a template and Elementor runs the widget’s render() method for the current post.
What the render method is doing
The useful part is very small:
if (
function_exists( 'get_field' ) &&
get_field( 'featured_items' )
) {
The function_exists() check prevents a fatal error if ACF is not active.
Then:
$featured_items = get_field( 'featured_items' );
gets the field for the current post.
Because Elementor is rendering the widget inside the current post or product context, ACF knows which object to read without needing an explicit post ID.
Then we loop over the returned values:
foreach ( $featured_items as $featured_item ) {
echo '<li>' . esc_html( $featured_item ) . '</li>';
}
esc_html() matters here.
Even if the field is editable only by trusted administrators, escaping output is still the right default. Data stored in WordPress should not automatically be treated as safe HTML just because you put it there yourself.
Do not call get_field() twice
The original version checked the field and then retrieved it again:
if ( function_exists( 'get_field' ) && get_field( 'featured_items' ) ) {
$featured_items = get_field( 'featured_items' );
That works, but there is no need to fetch the same field twice.
I would write it like this now:
if ( ! function_exists( 'get_field' ) ) {
return;
}
$featured_items = get_field( 'featured_items' );
if ( empty( $featured_items ) || ! is_array( $featured_items ) ) {
return;
}
That also verifies that the field actually contains the type of data the widget expects.
A cleaner version
If I were putting this into a site today, I would make the render method a little more defensive:
protected function render() {
if ( ! function_exists( 'get_field' ) ) {
return;
}
$featured_items = get_field( 'featured_items' );
if ( empty( $featured_items ) || ! is_array( $featured_items ) ) {
return;
}
echo '<div class="product-feature-notes">';
echo '<h2>Product Feature Notes</h2>';
echo '<ul>';
foreach ( $featured_items as $featured_item ) {
echo '<li>' . esc_html( $featured_item ) . '</li>';
}
echo '</ul>';
echo '</div>';
}
I generally would not print:
No featured items or notes found.
on the public site.
If there is no data, the widget should usually output nothing.
Visitors do not need to know that somebody forgot to populate an ACF field.
Making the heading editable
The original widget hard-coded:
echo '<h2>Product Feature Notes</h2>';
That is fine for a one-off internal widget.
If the widget is going to be reused, Elementor controls can expose the heading in the editor.
For example:
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __( 'Content', 'your-text-domain' ),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'heading',
[
'label' => __( 'Heading', 'your-text-domain' ),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => __( 'Product Feature Notes', 'your-text-domain' ),
]
);
$this->end_controls_section();
}
Then retrieve it inside render():
$settings = $this->get_settings_for_display();
if ( ! empty( $settings['heading'] ) ) {
echo '<h2>' . esc_html( $settings['heading'] ) . '</h2>';
}
Whether that is worth doing depends on the widget.
There is nothing wrong with hard-coding a heading when the widget exists for exactly one purpose.
Not every internal tool needs to become a framework.
Put this in a plugin, not your theme
The original code lived in a child theme’s functions.php.
That is convenient while testing something.
It is not where I would leave it.
The ACF field and Elementor widget describe site functionality, not presentation. If changing themes should not make the data disappear, the code belongs in a small site plugin.
Something as simple as:
wp-content/plugins/site-elementor-widgets/
site-elementor-widgets.php
is enough.
Then the widget survives future theme changes, which is exactly the problem I ran into years later when I found this code sitting in an old Astra child theme.
Do you even need a custom widget?
That is worth asking before writing one.
If Elementor can already dynamically output the ACF field in the exact format you need, use that.
A custom widget becomes useful when:
- the field contains multiple values
- the output needs custom markup
- the data needs additional processing
- several fields need to be combined
- editors should have one purpose-built widget instead of assembling several Elementor widgets
In this case, converting an array of ACF values into consistent list markup is enough of a reason.
The entire feature is small, understandable, and under your control.
That beats installing another 2 MB Elementor extension to print four <li> elements.