Elementor: Dynamic Tag types, fields & fallback
The body of a dynamic tag has three decisions: which base class (Tag vs Data_Tag), which categories it produces (so the right controls accept it), and which settings fields the editor shows. Plus the easily-missed fallback asymmetry between the two base classes. This skill assumes the tag is already being registered — see elementor-dynamic-tag-register for that.
Decision 1 — Tag vs Data_Tag
Tag |
Data_Tag |
|
|---|---|---|
| Extend | \Elementor\Core\DynamicTags\Tag |
\Elementor\Core\DynamicTags\Data_Tag |
| You implement | render() — echo the output |
get_value( array $options = [] ) — return the value |
get_content_type() |
'ui' (final) |
'plain' (final) |
| Use for | rendered text/HTML fragments (price, reading time, a badge) | structured values consumed by a control — an image array [ 'id' => …, 'url' => … ], a URL string, a color |
Verified: Tag::get_content() does ob_start(); $this->render(); $value = ob_get_clean(); (tag.php:30-37) and get_content_type() returns 'ui' (tag.php:64). Data_Tag declares abstract protected function get_value() (data-tag.php:25), returns it directly from get_content() (data-tag.php:43-45), and get_content_type() returns 'plain' (data-tag.php:31).
Rule of thumb: if the value feeds a MEDIA / IMAGE / URL / COLOR control (the control needs a structured value, not printed markup), use Data_Tag. If it feeds a TEXT context (it's printed inline), use Tag.
// Tag — echoes
class Reading_Time extends Tag {
public function render(): void {
echo esc_html( $this->compute() . ' min' ); // print, don't return
}
}
// Data_Tag — returns
class Featured_Image_Fallback extends Data_Tag {
protected function get_value( array $options = [] ) {
$id = get_post_thumbnail_id();
if ( $id ) {
return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
}
return $this->get_settings( 'fallback' ); // see "Fallback" below
}
}
Decision 2 — categories (what value the tag produces)
get_categories() returns one or more category constants from Elementor\Modules\DynamicTags\Module. Categories declare the kind of value the tag emits; a control accepts a tag only when their categories overlap, so the editor shows your tag only under compatible controls.
Verified constants (modules/dynamic-tags/module.php:31-76):
| Constant | Value | Typical use |
|---|---|---|
TEXT_CATEGORY |
'text' |
printed strings (most Tags) |
URL_CATEGORY |
'url' |
link fields |
IMAGE_CATEGORY |
'image' |
image controls |
MEDIA_CATEGORY |
'media' |
media (image/video) controls |
POST_META_CATEGORY |
'post_meta' |
meta-field contexts |
GALLERY_CATEGORY |
'gallery' |
gallery controls |
NUMBER_CATEGORY |
'number' |
number controls |
COLOR_CATEGORY |
'color' |
color controls |
DATETIME_CATEGORY |
'datetime' |
date/time controls |
SVG_CATEGORY |
'svg' |
inline-SVG / icon controls |
A tag may declare several — Pro's Post_Custom_Field returns [ TEXT, URL, POST_META, COLOR, DATETIME, MEDIA ] because a meta value can drive any of those controls. Reference the constant, never the bare string, so a renamed value can't break you.
use Elementor\Modules\DynamicTags\Module as TagsModule;
public function get_categories(): array {
return [ TagsModule::TEXT_CATEGORY ];
}
Decision 3 — settings fields via register_controls()
Add the tag's configuration fields in register_controls(). Elementor has already opened a "Settings" controls section around your call (base-tag.php:175-187) — add controls directly; do not wrap them in your own start_controls_section().
use Elementor\Controls_Manager;
protected function register_controls(): void {
$this->add_control( 'format', [
'label' => esc_html__( 'Format', 'myplugin' ),
'type' => Controls_Manager::SELECT,
'default' => 'minutes',
'options' => [
'minutes' => esc_html__( 'Minutes', 'myplugin' ),
'words' => esc_html__( 'Word count', 'myplugin' ),
],
] );
$this->add_control( 'wpm', [
'label' => esc_html__( 'Words / minute', 'myplugin' ),
'type' => Controls_Manager::NUMBER,
'default' => 200,
'min' => 50,
] );
}
Common control types (from Elementor\Controls_Manager, all seen in verified tags/widgets): TEXT, TEXTAREA, NUMBER, SELECT, SELECT2 (add 'multiple' => true for multi), SWITCHER, CHOOSE, COLOR, MEDIA, ICONS, ALERT, REPEATER. Read settings back with $this->get_settings( 'key' ) (raw) or $this->get_settings_for_display() (parsed). For a large-dataset picker (products/posts) use the AJAX query control — see elementor-dynamic-tag-ajax-select; a plain SELECT2 preloaded with thousands of options freezes the editor.
Name your method register_controls(), not _register_controls(). The underscore form is deprecated since 3.1.0 — init_controls() calls it but emits a _doing_it_wrong notice (base-tag.php:179-182). (The reference plugin used the underscore form until its 2026-06-17 migration; older copies and tutorials still show it, so recognise it but don't copy it.)
Two optional panel hints on Base_Tag:
is_settings_required()— returntrueif the tag is useless until configured (Pro'sPost_Custom_Fielddoes). Defaultfalse(base-tag.php:83).get_panel_template_setting_key()— return a control key to surface in the tag's panel label (e.g.'key'). Default''(base-tag.php:75).
The fallback system (the asymmetry that bites)
A Tag gets Before / After / Fallback controls for free and applies the fallback automatically when render() produces empty output. A Data_Tag gets none of this and must do it by hand.
Tag — automatic
Tag::register_advanced_section() adds an "Advanced" section with before, after, and fallback controls (tag.php:84-123), and get_content() applies them: if the rendered value is non-empty it prepends before / appends after; else if a fallback is set it uses wp_kses_post_deep( $settings['fallback'] ) (tag.php:39-55). You write render() and get fallback behaviour automatically — just make sure render() outputs nothing when there's no value (don't echo '0', '—', or an empty wrapper, or the fallback never triggers).
public function render(): void {
$value = $this->compute();
if ( '' === $value ) {
return; // emit nothing → Elementor's Fallback control takes over
}
echo esc_html( $value );
}
Data_Tag — manual
Data_Tag inherits the empty Base_Tag::register_advanced_section() (base-tag.php:166) — no Before/After/Fallback section is added, and get_content() does no fallback logic. To support a fallback you register the control yourself and consult it in get_value(). Pro's Post_Featured_Image is the canonical pattern:
protected function register_controls(): void {
$this->add_control( 'fallback', [
'label' => esc_html__( 'Fallback', 'myplugin' ),
'type' => Controls_Manager::MEDIA, // match the control type your value feeds
] );
}
protected function get_value( array $options = [] ) {
$id = get_post_thumbnail_id();
if ( $id ) {
return [ 'id' => $id, 'url' => wp_get_attachment_image_src( $id, 'full' )[0] ];
}
return $this->get_settings( 'fallback' ); // the manual fallback
}
Verified at post-featured-image.php:33-56 — get_value() returns the image array or $this->get_settings( 'fallback' ), and register_controls() adds a single MEDIA fallback control.
Critical rules
Tagechoes (render());Data_Tagreturns (get_value()). Mixing them up (returning fromrender(), or echoing fromget_value()) silently produces empty/garbage output.get_categories()returnsModule::*_CATEGORYconstants, never bare strings, and matches the control kinds your value feeds. Wrong categories → the tag never appears under the intended control.- Add controls directly in
register_controls()— Elementor already opened the "Settings" section. A self-opened section nests incorrectly. - Method is
register_controls(), not_register_controls()(deprecated 3.1.0). Tagfallback is automatic but only triggers on empty output —render()must emit nothing when there's no value.Data_Taghas no automatic fallback/before/after — register afallbackcontrol and read it inget_value()yourself, with atypematching the consuming control.- Escape on output in
Tag::render()(esc_html/wp_kses_post) — it's echoed into the page like any front-end output.
Common mistakes
// WRONG — Data_Tag that echoes (output is captured nowhere; control gets null)
class My_Image extends Data_Tag {
protected function get_value( array $options = [] ) {
echo wp_get_attachment_url( $id ); // <-- should RETURN
}
}
// WRONG — Tag that returns (nothing is printed; tag renders empty)
class My_Text extends Tag {
public function render() {
return get_the_title(); // <-- should ECHO
}
}
// WRONG — render() prints a placeholder, so Elementor's Fallback never fires
public function render() {
$v = $this->compute();
echo esc_html( $v ?: '—' ); // <-- non-empty → fallback control is dead
}
// RIGHT — emit nothing on empty
public function render() {
$v = $this->compute();
if ( '' === $v ) { return; }
echo esc_html( $v );
}
// WRONG — Data_Tag expecting an automatic Fallback control (there is none)
class My_Url extends Data_Tag {
protected function get_value( array $o = [] ) {
return $this->get_settings( 'fallback' ); // <-- 'fallback' was never registered
}
}
// RIGHT — register it first
protected function register_controls() {
$this->add_control( 'fallback', [ 'type' => Controls_Manager::URL ] );
}
// WRONG — wrapping controls in your own section
protected function register_controls() {
$this->start_controls_section( 'sec', [ 'label' => 'X' ] ); // <-- already inside "Settings"
$this->add_control( /* … */ );
$this->end_controls_section();
}
Cross-references
- Run
elementor-dynamic-tag-registerfor registering the tag, groups, hooks, and bootstrap timing. - Run
elementor-dynamic-tag-ajax-selectwhen a settings field must pick from a large dataset without a preloadedSELECT2. - See
reference.mdin this skill folder for the full control-type catalog and a complete workedData_Tag.
What this skill does NOT cover
- Registering the tag / groups / hooks —
elementor-dynamic-tag-register. - AJAX/search option fields —
elementor-dynamic-tag-ajax-select. - Group-control families (Typography, Box Shadow, etc.) — those are for widgets, not tag settings.
- The full
Controls_Managercontrol reference — see Elementor's controls docs; this skill cites only the types proven in dynamic-tag source.
References
Tag: wp-content/plugins/elementor/core/dynamic-tags/tag.php —get_content()ob/render/fallback (30-58),'ui'type (64), advanced section with before/after/fallback (84-123).Data_Tag: wp-content/plugins/elementor/core/dynamic-tags/data-tag.php — abstractget_value()(25),'plain'type (31),get_content()returns value (43-45).Base_Tag: wp-content/plugins/elementor/core/dynamic-tags/base-tag.php —init_controls()opens Settings +_register_controlsdeprecation (172-187), emptyregister_advanced_section()(166),is_settings_required()(83),get_panel_template_setting_key()(75).- Categories: wp-content/plugins/elementor/modules/dynamic-tags/module.php:31-76.
- Manual-fallback Data_Tag: wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/post-featured-image.php:33-56.
- Tag with SELECT + TEXT controls +
is_settings_required(): wp-content/plugins/elementor-pro/modules/dynamic-tags/tags/post-custom-field.php:46-105. - Official documentation: https://developers.elementor.com/docs/dynamic-tags/
- Official documentation: https://developers.elementor.com/docs/dynamic-tags/dynamic-tag-data/