Skip to content

Everything for WordPress, web development — and beyond

🎯 Selectors in Elementor widgets: complete guide for developers

🎯 Selectors in Elementor widgets: complete guide for developers

Why Elementor developers need selectors and how they work

When a user adjusts widget settings in the editor, they expect an instant response on screen. Without selectors, a developer would have to write a JS handler for every field change. With selectors, everything is solved by CSS.

The selectors parameter (and its less-known counterpart selectors_dictionary) is embedded directly in the add_control() call array. Elementor dynamically substitutes values from fields into CSS rules and outputs them to the post file, something like /wp-content/uploads/elementor/css/post-1234.css. As soon as the user exits the editor, inline styles disappear, leaving clean generated CSS.

💡 Quick overview:

  • Understand selectors syntax and the placeholder table
  • See live examples for color and sizes
  • Learn to pull values from neighboring controls
  • Master selectors_dictionary for CSS declaration substitution
  • Piece together the puzzle of CSS variables and hidden controls

Where selectors are defined

When you create a widget, each add_control() call accepts a settings array. That's exactly where selectors live. For group controls the syntax is the same, the array is passed inside the group registration.

Basic format:

1'selectors' => [
2 '{{WRAPPER}} .my-widget-class' => 'color: {{VALUE}}',
3]

The key is a CSS selector (starts with {{WRAPPER}} to avoid affecting neighboring widgets on the page). The value is one or more CSS declarations with dynamic placeholders. Elementor takes the current control value and substitutes it in place of the placeholder.

The result is rendered to the post's external CSS file, styles exist only while the editor is open and immediately after saving. No inline mess.

Curly brace variable table

No magic, just find-and-replace. But the variety of placeholders opens doors to quite clever constructions.

For selectors (array key)

Placeholder

What it substitutes

{{WRAPPER}}

Unique widget instance selector, for example .elementor-50 .elementor-element.elementor-element-092e113. Use almost always

{{ID}}

Only the widget ID (the part after the dash, 092e113)

(desktop) / (tablet) / (mobile)

Restricts the rule to the specified device. With + means "from this resolution and up": (tablet+) = tablet and wider

{{CURRENT_ITEM}}

Active element of a repeater control

For declarations (array value)

Placeholder

What it substitutes

{{VALUE}}

Raw control value. Can be overridden by selectors_dictionary

{{SIZE}} and {{UNIT}}

Number and unit of measurement from numeric controls. Usually go in pairs: {{SIZE}}{{UNIT}}

{{TOP}} / {{LEFT}} / {{RIGHT}} / {{BOTTOM}}

Directions from dimensions control

{{URL}} or other name

Access to named property of composite controls: for example, Media Control returns an array with fields url id alt

{{other.SIZE}}

Value of another control by ID. Suffixes _tablet and _mobile provide responsive data

{{setting.SIZE \|\| 5}}

Fallback: if the control is empty, 5 will be substituted. Works with strings in quotes and with another control's DEFAULT

Simple examples, from color to background image

Color from palette. Nothing extra:

1'selectors' => [
2 '{{WRAPPER}} .elementor-svg-divider-basic-text' => 'color: {{VALUE}}',
3],

Numeric control with and without unit. The second property (stroke-width) is intentionally without {{UNIT}}, stroke thickness is in pixels, without px:

1'selectors' => [
2 '{{WRAPPER}} svg.sde-classic' =>
3 'height: {{SIZE}}{{UNIT}}; stroke-width: {{SIZE}};',
4],

Spacing from dimensions control, each direction separately:

1'selectors' => [
2 '{{WRAPPER}} .elementor-svg-divider-basic-button' =>
3 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
4],

Background image of a slide in repeater control:

1'selectors' => [
2 '{{WRAPPER}} {{CURRENT_ITEM}} .swiper-slide-bg' =>
3 'background-image: url({{URL}})',
4],

Conditional position for RTL. The same control provides different properties depending on text direction:

1'selectors' => [
2 'body:not(.rtl) {{WRAPPER}} .dialog-close-button' => 'right: {{SIZE}}{{UNIT}}',
3 'body.rtl {{WRAPPER}} .dialog-close-button' => 'left: {{SIZE}}{{UNIT}}',
4],

How to pull a value from another control

If two fields affect the same CSS, don't duplicate the array, just reference the neighboring control:

1'selectors' => [
2 '{{WRAPPER}} svg.sde-classic' =>
3 'stroke-dasharray: {{dash_length.SIZE}} {{whitespace_length.SIZE}};',
4],

Here dash_length and whitespace_length are IDs of other controls in the same widget. No additional calls, just dot notation.

Responsive version, values are pulled considering the device. Real example from Elementor Pro:

1'selectors' => [
2 '(desktop).elementor-msie {{WRAPPER}} .elementor-portfolio-item' =>
3 'width: calc( 100% / {{columns.SIZE}} ); border: {{SIZE}}px solid transparent',
4 '(tablet).elementor-msie {{WRAPPER}} .elementor-portfolio-item' =>
5 'width: calc( 100% / {{columns_tablet.SIZE}} ); border: {{SIZE}}px solid transparent',
6 '(mobile).elementor-msie {{WRAPPER}} .elementor-portfolio-item' =>
7 'width: calc( 100% / {{columns_mobile.SIZE}} ); border: {{SIZE}}px solid transparent',
8],

Each breakpoint gets its own columns value. The remaining properties (border, SIZE) are common, they aren't tied to the device.

selectors_dictionary, switch-case for CSS

The main underestimated capability. selectors_dictionary replaces {{VALUE}} with a hard-coded string, essentially turning the control value into a dictionary key.

Take the standard Align control with left/center/right options. Without a dictionary you would write something unnatural:

1'selectors' => [
2 $sde_selector => 'margin: 0 auto; margin-{{VALUE}}: 0;',
3],

For center this produces margin: 0 auto; margin-center: 0;. The margin-center property doesn't exist, the browser silently ignores it. But it looks messy.

The dictionary does the same thing cleanly:

1'selectors_dictionary' => [
2 'left' => 'margin-right: auto',
3 'center' => 'margin: 0 auto',
4 'right' => 'margin-left: auto',
5],
6'selectors' => [
7 '{{WRAPPER}} .sde' => '{{VALUE}}',
8],

Control value center{{VALUE}} becomes margin: 0 auto. That's it.

Important limitation: after activating selectors_dictionary you lose the original {{VALUE}}. If the same array has another selector-declaration pair that needs the original value, it will receive the already-substituted string. Here's a problematic example:

1'selectors' => [
2 '{{WRAPPER}} .sde' => '{{VALUE}}',
3 '{{WRAPPER}}.elementor-sde-scale-the-cropped .sde-cropping-allow .sde' =>
4 'transform-origin: {{VALUE}} 0;',
5],

Here transform-origin will receive margin: 0 auto 0; instead of center 0;. Solution: extract dependent declarations into a separate control.

The dictionary also handles translation of individual CSS values well:

1'selectors_dictionary' => [
2 'top' => 'flex-start',
3 'middle' => 'center',
4 'bottom' => 'flex-end',
5],
6'selectors' => [
7 '{{WRAPPER}} .elementor-price-table__currency' => 'align-self: {{VALUE}}',
8],

And even with entire sets of declarations, one key → multiple CSS properties:

1'selectors_dictionary' => [
2 'left' => 'right: auto; left: 0',
3 'right' => 'left: auto; right: 0',
4],
5'selectors' => [
6 '{{WRAPPER}}.elementor-wc-products ul.products li.product span.onsale' => '{{VALUE}}',
7],

CSS variables, calc() and hidden controls, assembling the puzzle

The true power of selectors is revealed in combination. One control sets a CSS variable, another references it, a third enables/disables an entire block of rules through a condition.

The Scale% slider writes a variable:

1'selectors' => [
2 '{{WRAPPER}} .sde' => '--sde-scale-percentage: {{SIZE}};',
3],

The "Scale cropped" toggle uses this variable in two places, both for transform and for passing to the Gap control:

1'selectors' => [
2 '{{WRAPPER}} .sde' =>
3 'transform: scale(var(--sde-scale-percentage)) scale(0.01);
4 --sde-scale-pct-for-gap: var(--sde-scale-percentage);',
5],

Hidden control with condition, the same transform but with a different selector (for uncropped state):

1'condition' => [
2 'scale_the_cropped!' => 'cropped',
3],
4'selectors' => [
5 '{{WRAPPER}} .sde svg' =>
6 'transform: scale(var(--sde-scale-percentage)) scale(0.01);',
7],

And the Gap control uses the passed variable with fallback:

1'selectors' => [
2 '{{WRAPPER}} .sde' =>
3 'padding: calc({{SIZE}}{{UNIT}} / (var(--sde-scale-pct-for-gap, 100) / 100)) 0;',
4],

What's happening here: Gap compensates for scaling. If an element is shrunk by half, the gap is multiplied by 2 to visually remain the same. Without shrinking (variable not set) the fallback 100 kicks in → division by 1 → gap doesn't change. Pure CSS math, without a single line of JS.

Transform stacking, workaround for Edge

The construction scale(X) scale(0.01) deserves special mention. Why not scale(calc(var(--sde-scale-percentage) / 100))? Because Edge doesn't support calc() inside transform. At all.

Solution: stacking. Browsers apply transform functions sequentially, one after another. Therefore:

1transform: scale(var(--sde-scale-percentage)) scale(0.01);

Mathematically equivalent to scale(var(--sde-scale-percentage) * 0.01), in other words division by 100. The user gets a familiar 0-100 slider, while under the hood the value turns into a 0-1 coefficient.

The same principle applies to other transformations: rotate, translate, skew, and works in all modern browsers, including Edge.

⁉️🤔 Frequently asked questions

What exactly goes into the generated CSS file?

Elementor collects all selectors from registered widget controls, substitutes current values from user settings and writes the result to /wp-content/uploads/elementor/css/post-XXXX.css. These aren't inline styles and not dynamic CSS on the fly: it's a static file that's cached by the browser and lives until the next settings change in the editor. The placeholders themselves {{VALUE}} {{SIZE}} and others are not part of WordPress or Blade templating engine: Elementor does a regular str_replace during CSS generation, iterating through all selector-declaration pairs and replacing tokens with actual control values.

How do I debug selectors if CSS isn't applying?

Open the post's generated CSS file (path is visible in the page source) and check that the rule is there. If the rule is missing, look for a typo in the control ID or a syntax error in the selectors array. If the rule is there but not working, check selector specificity: {{WRAPPER}} provides high priority, but nested themes can override through !important. Enable WP_DEBUG and watch PHP logs: Elementor silently skips incorrect arrays without displaying errors on screen. Use {{WRAPPER}} ALWAYS, except for cases of intentional targeting of body or html.

How do selectors differ from custom CSS in widget settings?

Custom CSS (Advanced tab) is written manually by the user, these are static rules that don't react to settings changes. Selectors dynamically link controls with CSS: turn the slider, width changes, switch Align, margin rebuilds. The user doesn't see this mechanism, they just get a live preview. For the developer the main win is the absence of the _content_template() method: without selectors you would have to write JS preview rendering for each control.

Do I need selectors_dictionary if I already use selectors?

Yes, for a qualitative leap in code cleanliness. Without a dictionary you process the control value implicitly, through strange CSS properties like the non-existent margin-center, which the browser ignores. With a dictionary you explicitly specify: "if the value is left, substitute margin-right: auto, if center, margin: 0 auto". Code becomes self-documenting, and most importantly, {{VALUE}} no longer drags the original control value into other declarations of the same array.

Can I combine selectors with _content_template() in one widget?

Technically yes, but in practice this is a signal to reconsider the architecture. If selectors suffice for most controls, but a couple of fields require JS rendering, extract the JS logic into a separate method and call it precisely. Complete rejection of selectors in favor of _content_template() means you're writing a JS duplicate of all PHP control logic, supporting such a widget quickly becomes a problem.

Is it worth mastering selectors in 2026

Elementor continues to develop atomic infrastructure, Variables Manager, Grid and Flexbox containers, global styles. But the foundation of widget mechanics hasn't changed since version four: selectors and selectors_dictionary remain the primary way to link a control with live preview.

By mastering this technique, you eliminate a good half of all JS logic in a typical widget. Instead of handlers for every field, one selectors array per control. Instead of complex positioning in preview, a combination of CSS variables with calc() and a couple of hidden controls. The SVG Divider for Elementor plugin is a live example: more than half of its controls are managed exclusively through selectors, without a single _content_template() call.

The main rule is don't overcomplicate. If you catch yourself writing a fourth nested calc() with three variables, stop. Perhaps it's simpler to add a hidden intermediary control or split the logic into two separate fields. And Elementor source code is the best textbook: the add_control_rules() method in core/files/css/base.php shows how selectors are processed internally.