Loop Grid Search for Elementor
AJAX keyword / date / taxonomy search and filtering for any post type, rendered server-side through an Elementor loop template. Ships as both a shortcode and a drag-and-drop "Loop Grid Search" Elementor widget.
by Chris Paschall · github.com/magellan-web-dev/loop-grid-search-for-elementor
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/magellan-web-dev/loop-grid-search-for-elementor/archive/refs/heads/main.zipAJAX keyword / date / taxonomy search and filtering for any post type, rendered server-side through an Elementor loop template. Ships as a drag-and-drop Loop Grid Search Elementor widget and as a shortcode.
- Requires: WordPress 6.0+, PHP 8.1+
- Recommended: Elementor (for the widget and template rendering)
- Optional: Elementor Pro (only to author Loop Item templates), ACF (only to manage the custom field)
- Tested with: WordPress 6.9.5, Elementor 4.1.4, Elementor Pro 4.1.2, ACF 6.8.5
Table of contents
- What it does
- Quick start
- Elementor setup
- Shortcode reference
- Pagination
- How the title OR custom field search works
- Combining filters
- Changing the post type, custom field, or taxonomy later
- Result rendering
- Architecture
- The AJAX endpoint
- Security model
- Front-end behaviour
- CSS reference
- Filter reference
- Performance notes
- Compatibility notes and known limitations
- Updates
- Changelog
What it does
One interface with four controls and an AJAX-updated result grid:
| Control | Behaviour |
|---|---|
| Keyword | Live search, debounced ~400 ms, Enter searches immediately. Matches any combination of post title / excerpt / content OR any number of custom fields — all OR-ed together. Post Title is on by default. |
| Month / Year | Built from the dates that actually exist for the selected post type. Nothing is hard-coded. Newest month first. |
| Taxonomy term | Any public taxonomy — post_tag by default, or a custom one such as resource_type. Add as many dropdowns as you need (Category and Tag), and each lists only the terms the queried post type actually uses. |
| Clear Filters | Resets keyword, date, every term, sort and pagination, then reloads the unfiltered first page. |
| Sort (optional) | Newest / Oldest / Title A–Z / Title Z–A. Off by default. |
| Pagination | Previous / Next, with or without numbered pages. Button text is configurable and the number list truncates to a sliding window so it never runs off the page. |
The page never reloads — for searching, filtering, clearing, sorting or paging. Every response is finished HTML rendered on the server in a single request.
Quick start
- Activate the plugin.
- Edit a page with Elementor, search the widget panel for Loop Grid Search, and drag it in.
- Set Post Type, Also Search Custom Field (default
excerpt), and Taxonomy (defaultpost_tag) — plus a row under More Taxonomy Filters for each additional term dropdown you want. - Optionally pick a Result Template under Content ▸ Results to render each card with Elementor.
- Publish.
Or, anywhere a shortcode works:
[ajax_post_search]
Elementor setup
Using the widget
The widget appears in the panel under a Loop Grid Search category (and in General). Its controls:
Content ▸ Query
| Control | Default | Notes |
|---|---|---|
| Post Type | post |
Public post types only; attachment excluded. |
| Results Per Page | 9 |
1–100. |
| Default Order By | Published Date | Date / Title / Last Modified / Menu Order / Post ID. |
| Default Direction | Descending | This is the order Clear Filters returns to. |
Content ▸ Keyword Search
| Control | Type | Default | Notes |
|---|---|---|---|
| Show Keyword Field | Toggle | On | |
| Search In | Multi-select | Post Title |
Built-in post fields: Post Title, Post Excerpt, Post Content. Pick any combination. |
| Also Search Custom Fields | Multi-select | — | ACF fields, discovered automatically, listed as Label — meta_key (Post Type). Only text-style types appear. |
| Additional Meta Keys | Text | excerpt |
Comma-separated meta keys for fields ACF does not manage, or when ACF is not installed. |
| Field Label / Placeholder | Text | Search / Search… |
All three search controls are OR-ed together. Out of the box that is post title OR the excerpt meta field, matching the plugin's original behaviour.
The Also Search Custom Fields picker only lists ACF field types whose stored value is the searchable text — Text, Textarea, WYSIWYG, Email, URL, Number, Range, Select, Radio, Button Group. Types that store a serialized array or a foreign ID (Relationship, Post Object, Image, Gallery, Repeater, Group, Flexible Content, Link, Taxonomy, User) are deliberately excluded: a LIKE against their raw storage either never matches or matches nonsense, and listing them would be a promise the search cannot keep. Use the lgs_search_field_options filter to override that judgement either way.
If no ACF fields are detected, the control says so and points you at Additional Meta Keys instead.
ACF is not required at query time. It is used only to discover field names for this picker. The search itself reads post meta directly with
$wpdb, so a site with no ACF works fine — type the meta keys by hand.
Content ▸ Filters — visibility toggles and labels for the Month/Year filter, the taxonomy filters, the optional sort dropdown, and the Clear button. The taxonomy half of that section:
| Control | Type | Default | Notes |
|---|---|---|---|
| Show Taxonomy Filter | Toggle | On | Off hides every taxonomy dropdown. |
| Taxonomy | Select | Tag | Any public taxonomy. Each option reads Singular (slug) — Post Types, so a taxonomy that has nothing to do with the post type above is visible as such while choosing. |
| Taxonomy Label | Text | — | Empty uses the taxonomy's own registered name — Tag, Category, Resource Type. |
| "All Terms" Option Text | Text | — | Empty uses All + the plural name, e.g. All Categories. |
| Only Terms Used By This Post Type | Switcher | On | Lists only terms that a published post of the selected post type actually has. See Scoping terms to the post type. |
| More Taxonomy Filters | Repeater | — | One extra dropdown per row, each with its own Taxonomy, Label and "All Terms" text. See Several taxonomy dropdowns. |
Scoping terms to the post type
WordPress counts terms per taxonomy, not per post type. When category is shared between post and resource, a category used only by blog posts still passes hide_empty and still appears in a dropdown that queries resources — where selecting it can only ever return nothing.
Only Terms Used By This Post Type resolves the terms that a published post of the queried post type genuinely carries and offers only those. Leave it on unless you have a reason not to: for a taxonomy used by a single post type the list is identical either way, and for a shared one it removes options that are dead ends.
A dropdown left with no terms at all is not rendered — an empty select whose only entry is "All Categories" is worse than no select.
Scoping is by post type, not by the visitor's current filters. The options do not re-narrow as a keyword or another dropdown is used; a term that returns nothing in combination with the other filters is still listed.
Several taxonomy dropdowns
The Taxonomy control above is the first dropdown; every More Taxonomy Filters row adds another, in panel order. A resource library can therefore filter by Resource Type and Topic side by side.
Selections narrow together — choosing a category and a tag returns only posts carrying both — and each dropdown owns its own query parameter, so the combination survives a reload or a shared link. Rows repeating a taxonomy already in use, or left with no taxonomy chosen, are ignored. Ten dropdowns is the hard ceiling.
Content ▸ Results
| Control | Default | Notes |
|---|---|---|
| Result Template | Built-in PHP card | Grouped dropdown of your Elementor library, Loop Item templates first. |
| No Results Message | "No results found matching your search." |
Content ▸ Pagination
| Control | Type | Default | Notes |
|---|---|---|---|
| Pagination Style | Select | Previous / Next + Page Numbers | The alternative, Previous / Next Only, shows a "Page 2 of 5" counter instead of numbered buttons. |
| Previous Button Text | Text | Previous | |
| Next Button Text | Text | Next | |
| Max Page Numbers Shown | Number | 6 |
3–50. Beyond this many pages the list truncates with an ellipsis. Only shown in the numbered style. |
| SEO-Friendly Page Links | Switcher | On | Renders pages as crawlable <a href="?lgs_page=2"> links and keeps the URL in step with the visitor's filters. Clicks are still handled without a reload. Turn off on the second search widget of a page that has two. |
See Pagination below for exactly how truncation behaves.
Style ▸ Results Grid — responsive Columns and Gap. These write CSS custom properties, so they cascade normally.
Building the result template
- Templates ▸ Theme Builder ▸ Loop Item ▸ Add New (Elementor Pro), or Templates ▸ Saved Templates for a plain section/container.
- Design one card using dynamic widgets — Post Title, Featured Image, Post Excerpt, ACF field, Post URL, taxonomy terms. They all resolve against the correct result post; see Result rendering.
- Save, then select it in the widget's Result Template dropdown (or pass its ID as
elementor_template_idto the shortcode).
A Loop Item template is the natural choice, but any Elementor-built template works — the plugin only asks that the post was built with Elementor.
Using the shortcode inside Elementor
Drop Elementor's Shortcode widget onto the page and put [ajax_post_search …] inside it. Everything behaves identically; only the Style-tab grid controls are unavailable (use the columns and gap attributes instead).
Shortcode reference
Both tags are identical:
[loop_grid_search]
[ajax_post_search]
Full example:
[ajax_post_search
post_type="post"
search_in="post_title,post_excerpt"
acf_search_field="excerpt,summary"
taxonomy="post_tag"
posts_per_page="9"
elementor_template_id="1234"
columns="3"
gap="24"
show_sort="yes"]
Both list attributes accept a single value too, so the original one-field form still works unchanged:
[ajax_post_search post_type="post" acf_search_field="excerpt" taxonomy="post_tag" posts_per_page="9"]
Attributes
| Attribute | Default | Description |
|---|---|---|
post_type |
post |
Any public post type. |
search_in |
post_title |
Comma-separated built-in fields: post_title, post_excerpt, post_content. (search_columns is a synonym.) |
acf_search_field |
excerpt |
Comma-separated meta keys OR-ed with the fields above. Empty = built-in fields only. (meta_search_field and search_meta_keys are synonyms.) |
taxonomy |
post_tag |
Any public taxonomy — the first term dropdown. Empty disables the term filter entirely. |
taxonomies |
— | Comma-separated taxonomies for additional term dropdowns, appended after taxonomy. taxonomies="resource_type,post_tag" renders two. Unknown or repeated entries are dropped; ten dropdowns is the ceiling. |
taxonomy_terms_in_post_type |
yes |
Offer only terms that a published post of post_type actually has. no reverts to WordPress's per-taxonomy hide_empty, which keeps terms used only by other post types. See Scoping terms to the post type. |
posts_per_page |
9 |
Clamped to 1–100. |
elementor_template_id |
— | Elementor template post ID. Omit to use the PHP card. (template_id is a synonym.) |
orderby |
date |
date, title, modified, menu_order, ID. |
order |
DESC |
DESC or ASC. |
columns |
3 |
1–8. |
gap |
24 |
Grid gap in pixels, 0–200. |
pagination_mode |
numbers |
numbers = Previous/Next + page numbers. prev_next = Previous/Next + a page counter. |
pagination_max_numbers |
6 |
3–50. Max numbered buttons before the list truncates with an ellipsis. |
pagination_prev_label |
Previous |
Previous button text. |
pagination_next_label |
Next |
Next button text. |
pagination_numbers |
— | Legacy boolean spelling. no is equivalent to pagination_mode="prev_next". |
seo_pagination |
yes |
no reverts to non-crawlable buttons and stops the plugin touching the URL. See SEO-friendly pagination. |
show_keyword |
yes |
|
show_date |
yes |
|
show_taxonomy |
yes |
|
show_sort |
no |
|
show_clear |
yes |
|
keyword_label |
Search | |
keyword_placeholder |
Search… | |
date_label |
Date | |
date_all_label |
All Dates | |
taxonomy_label |
the taxonomy's own name | Label for the first dropdown. Empty uses the registered singular name. |
taxonomy_all_label |
All + the plural name |
"All terms" text for the first dropdown. |
taxonomy_labels |
— | Labels for the other dropdowns: taxonomy_labels="post_tag:Topic\|category:Section". Pipe-separated, because a label may contain a comma. |
taxonomy_all_labels |
— | Same format, for their "all terms" text: taxonomy_all_labels="post_tag:All Topics". |
sort_label |
Sort By | |
clear_label |
Clear Filters | |
no_results_text |
No results found matching your search. |
Invalid values degrade to the default rather than erroring — a typo in a shortcode never takes a page down.
How the title OR custom field search works
Why the obvious approach is wrong
The tempting version is s plus a meta_query:
// DOES NOT WORK — requires BOTH to match.
new WP_Query([
's' => 'solar',
'meta_query' => [[ 'key' => 'excerpt', 'compare' => 'LIKE', 'value' => 'solar' ]],
]);
WordPress builds the search clause and the meta clause as two independent WHERE fragments and joins them with AND:
AND ( post_title LIKE '%solar%' OR post_excerpt LIKE … OR post_content LIKE … )
AND ( postmeta.meta_key = 'excerpt' AND postmeta.meta_value LIKE '%solar%' )
A post whose title contains "solar" but whose ACF field does not is therefore excluded. No relation spans the search clause and the meta clause, so the two can never be OR-ed through the public API.
What this plugin does instead
s is never set. The keyword travels on a private query var and a tightly scoped posts_clauses filter appends one self-contained group to the WHERE clause. With the default configuration (title + the excerpt field):
AND (
( wp_posts.post_title LIKE '%solar%'
OR EXISTS ( SELECT 1 FROM wp_postmeta AS lgs_meta
WHERE lgs_meta.post_id = wp_posts.ID
AND lgs_meta.meta_key IN ('excerpt')
AND lgs_meta.meta_value LIKE '%solar%' ) )
)
Searching solar returns a post when either side matches. Neither side is required.
Every field you select simply joins the same OR chain. Title + excerpt + content, plus the excerpt and summary meta fields:
AND (
( wp_posts.post_title LIKE '%solar%'
OR wp_posts.post_excerpt LIKE '%solar%'
OR wp_posts.post_content LIKE '%solar%'
OR EXISTS ( SELECT 1 FROM wp_postmeta AS lgs_meta
WHERE lgs_meta.post_id = wp_posts.ID
AND lgs_meta.meta_key IN ('excerpt', 'summary')
AND lgs_meta.meta_value LIKE '%solar%' ) )
)
Five deliberate design points:
EXISTS, not JOIN. A LEFT JOIN on wp_postmeta duplicates a post row per matching meta row, which would need a GROUP BY to de-duplicate and would distort SQL_CALC_FOUND_ROWS (and therefore the page count). A correlated EXISTS subquery returns each post exactly once and resolves through wp_postmeta's post_id index.
One EXISTS for all meta keys. However many custom fields you select, they become a single meta_key IN (…) subquery rather than one subquery per field. Adding fields is close to free: the correlated lookup still visits only the current post's meta rows.
Multi-word keywords are AND across terms, OR across fields. Searching solar panels requires both words to appear, and each word may appear in either the title or the custom field. This mirrors WordPress core's own multi-term search behaviour and reduces to the single-field OR case for a one-word query. Terms are capped at 10.
The filter's lifetime is one query. KeywordSearch::run() adds the filter, runs exactly one WP_Query, and removes the filter in a finally block — so it is gone even if rendering throws. The callback also verifies the query carries the plugin's private query var, so any unrelated query running inside that window is returned byte-identical.
Every value is bound; column names are allowlisted. The keyword goes through $wpdb->esc_like() and is bound with $wpdb->prepare(); meta keys are bound as parameters too. Column names cannot be bound as SQL parameters, so they are re-checked against FieldRegistry::SEARCHABLE_COLUMNS inside KeywordSearch itself — independently of Config — before being interpolated. post_password and anything else is dropped there even if it somehow reached that far. Otherwise only the $wpdb->posts / $wpdb->postmeta table names are interpolated.
See src/Query/KeywordSearch.php and src/Support/FieldRegistry.php.
Combining filters
All filters compose with AND:
Keyword: solar Month: August 2026 Tag: Commercial Category: Case Studies
becomes
( wp_posts.post_title LIKE '%solar%'
OR EXISTS ( postmeta row: meta_key='excerpt' AND meta_value LIKE '%solar%' ) )
AND post_date is in 2026-08 -- WP_Query date_query
AND post has term "Commercial" in post_tag -- WP_Query tax_query
AND post has term "Case Studies" in category -- WP_Query tax_query
Each taxonomy dropdown contributes its own tax_query clause, so two selections narrow the results rather than widening them: a post must carry the chosen term in every dropdown the visitor has used.
Changing any filter resets pagination to page 1. Changing the page preserves the keyword, month/year, every selected term and the sort.
Changing the post type, custom field, or taxonomy later
All three are configuration, in one place per instance.
Widget: Content ▸ Query ▸ Post Type, Content ▸ Keyword Search ▸ Search In / Also Search Custom Fields / Additional Meta Keys, Content ▸ Filters ▸ Taxonomy (plus More Taxonomy Filters for further dropdowns).
Shortcode:
[ajax_post_search post_type="resource" acf_search_field="summary,body" taxonomies="resource_type,post_tag"]
Nothing else has to change:
- The Month / Year options are generated from the new post type's actual publish dates.
- Each taxonomy dropdown is generated from that taxonomy's terms — narrowed, by default, to the ones the new post type actually uses, and dropped entirely if it uses none.
- The keyword search binds the new meta keys as query parameters.
- The ACF field picker re-discovers fields and promotes the ones bound to the new post type to the top of the list.
- The built-in card tries each searchable meta key in order and shows the first one with a value as its summary, falling back to the WordPress excerpt.
Three rules worth knowing:
- Only public post types and public taxonomies are accepted. An unregistered or private slug falls back to the default.
- A custom field is a meta key, not an ACF field key (
field_abc123). For an ACF field namedexcerpt, useexcerpt. - At most 20 meta keys per instance, so a crafted payload cannot generate an unbounded
IN()clause.
To search built-in fields only, clear the custom fields:
[ajax_post_search acf_search_field=""]
To search custom fields only, clear the built-in ones:
[ajax_post_search search_in="" acf_search_field="excerpt,summary"]
Clearing both falls back to Post Title — a keyword box with nothing behind it reads as a broken search rather than a configuration choice.
Pagination
Two styles
| Style | Renders |
|---|---|
| Previous / Next + Page Numbers (default) | ‹ Previous 1 2 3 4 5 6 … Next › |
| Previous / Next Only | ‹ Previous Page 2 of 5 Next › |
Both styles share the same Previous and Next controls, whose text you set with Previous Button Text / Next Button Text (or the pagination_prev_label / pagination_next_label shortcode attributes).
Previous on page 1 and Next on the last page are inert <span aria-disabled="true"> elements rather than links — there is no destination, so there is nothing to link to — and the current page carries aria-current="page".
SEO-friendly pagination
Every page is a real URL. Page 3 of a result set lives at ?lgs_page=3, and the pagination renders as ordinary links:
<a class="ajax-post-search__page ajax-post-search__page--number"
href="https://example.com/news/?lgs_page=3#lgs-1"
data-lgs-page="3">3</a>
That gets you three things at once:
- Crawlable. Search engines follow the links and index every page of results, instead of seeing page 1 and a dead end.
- Shareable and reloadable. Loading
?lgs_page=3renders page 3 server-side, so a bookmark, a pasted link or a browser refresh reproduces exactly what was on screen. - Still instant. A plain left-click is intercepted with
preventDefault()and the results are swapped over AJAX — no page reload, no scroll jump, filters preserved. Only the address bar changes, viahistory.pushState().
Modified clicks are deliberately left alone, so Cmd/Ctrl-click and middle-click open a page of results in a new tab the way they do on any other link. Back and Forward work too: popstate re-reads the URL, syncs the filter controls to it and re-runs the search.
Filters ride along in the same URL, so a filtered view is shareable as well:
| Parameter | Carries |
|---|---|
lgs_page |
1-based page number — omitted on page 1, so the first page has exactly one address |
lgs_q |
keyword |
lgs_date |
YYYY-MM month selection |
lgs_term_<taxonomy> |
term ID chosen in that taxonomy's dropdown — lgs_term_category=4&lgs_term_post_tag=9 |
lgs_sort |
sort preset key |
Term parameters carry the taxonomy slug because an instance may render several dropdowns, which one lgs_term cannot describe; each parameter also keeps its meaning if the dropdowns are later reordered. The bare lgs_term is still read, as the first configured taxonomy's term, so links shared or indexed before this scheme existed keep resolving to the same results — it is simply never written any more.
All of them are prefixed and none is a registered WordPress query var. In particular lgs_page is deliberately not paged, which would make WordPress treat the request as a paged archive and produce 404s and canonical redirects on a singular page. Query parameters this plugin does not own are preserved untouched when the URL is rewritten.
Two searches on one page. One set of query parameters cannot describe two instances, so they would page in lockstep on reload. Switch SEO-Friendly Page Links off (or pass seo_pagination="no") on the secondary one: it goes back to <button> controls and stops reading or writing the URL, while the primary instance keeps its crawlable links.
Styling note. Because the controls are now <a> and <span> elements rather than <button>, a theme styles them as links. The plugin ships neutral defaults — padding, a currentColor border, no underline — wrapped in :where() so their specificity is zero and any theme rule, Elementor style or single custom class overrides them without !important.
Number truncation
Max Page Numbers Shown (default 6) caps how many numbered buttons are on screen at once. The visible window is centred on the current page and clamped at both ends, with an ellipsis marking each truncated side. The button count never grows with the result set.
With 9 pages of results and a limit of 6:
| Current page | Rendered |
|---|---|
| 1 | 1 2 3 4 5 6 … |
| 2 | 1 2 3 4 5 6 … |
| 3 | 1 2 3 4 5 6 … |
| 4 | … 2 3 4 5 6 7 … |
| 5 | … 3 4 5 6 7 8 … |
| 6 | … 4 5 6 7 8 9 |
| 7–9 | … 4 5 6 7 8 9 |
The window slides once the current page moves past the centre, so there is always roughly equal context on either side of where the visitor is. When the total page count is at or under the limit, every page is listed and no ellipsis appears.
The ellipsis is a non-interactive <span aria-hidden="true">; Previous / Next and the visible numbers are the navigation. This keeps the control's tab order short and predictable.
Guaranteed for every combination of limit, total pages and current page:
- exactly
min(limit, total)numbers are shown - the current page is always among them
- the numbers are always contiguous and in range
- a leading ellipsis appears exactly when pages are hidden before the window, and a trailing one exactly when pages are hidden after it
Truncating in the shortcode
[ajax_post_search
pagination_mode="numbers"
pagination_max_numbers="6"
pagination_prev_label="‹ Newer"
pagination_next_label="Older ›"]
Previous / Next only:
[ajax_post_search pagination_mode="prev_next"]
Result rendering
Shortcode / widget config
↓
AJAX request (one)
↓
WP_Query (paginated)
↓
Server-side rendering, per result
↓
Elementor template ─or─ PHP card
↓
Finished HTML in one JSON response
With a Loop Item template
Loop Item templates take a dedicated path, because Elementor has two stylesheet pipelines and they are not interchangeable:
| Document type | CSS class | File | Handle |
|---|---|---|---|
| Section / container / page | Elementor\Core\Files\CSS\Post |
post-{id}.css |
elementor-post-{id} |
| Loop Item | Pro's …\LoopBuilder\Files\Css\Loop |
loop-{id}.css |
loop-{id} |
Both classes share the same _elementor_css post meta key. So calling the generic render API on a Loop Item template reads Pro's meta, sees status file, and enqueues post-{id}.css — a file Elementor never generated. The request 404s and the loop items render unstyled.
The plugin therefore routes Loop Item documents through $document->get_content(), which is exactly what Elementor Pro's own Loop Grid does (its skin calls Theme_Document::print_content(), a thin echo $this->get_content()). Pro's Loop::get_content() installs its prevent_inline_css_printing filter, emits the correct loop-{id} stylesheet — including the template's Custom CSS — and returns the markup.
Per-result dynamic CSS is emitted too. Styles driven by dynamic tags (a background image from an ACF field, a per-post colour) are generated per post and have their selectors rewritten from .elementor-{post} to .e-loop-item-{post}, so they cannot be printed once and reused like the template stylesheet.
Both calls echo rather than return, so the plugin buffers them and prepends the captured CSS to the card markup — which keeps an AJAX response self-contained.
If Elementor Pro is absent or a future release moves these classes, the plugin falls back to the generic path rather than fataling (and re-throws under WP_DEBUG so the breakage is visible while developing).
With any other Elementor template
Each result is rendered through Elementor's public, documented render API:
\Elementor\Plugin::$instance->frontend->get_builder_content_for_display( $template_id, false );
This is the same call behind Elementor Pro's own Template widget and the [elementor-template] shortcode — verified against the installed Elementor 4.1.4 source. The plugin uses no private or minified Elementor JavaScript, and never touches the undocumented /wp-json/elementor-pro/v1/refresh-loop endpoint.
WordPress post context
WP_Query::the_post() sets $GLOBALS['post'] and calls setup_postdata() before each card renders. That is exactly the context Elementor dynamic tags and widgets expect, and the same mechanism Elementor Pro's Loop Grid relies on — so Post Title, Featured Image, Post Excerpt, ACF fields, Post URL and taxonomy terms all resolve against the current result post.
Because a shortcode normally runs inside the page's main loop, the surrounding $GLOBALS['post'] is captured before the loop and restored afterwards, on top of calling wp_reset_postdata() — even if rendering throws. The search query is a secondary WP_Query; the main query is never touched and no conditional tag is affected.
Rendering a template once per post: the two real costs
Both are called out in code comments as well.
CSS duplication. Inside an AJAX request Elementor forces its $with_css flag on and prints the template stylesheet inline, so a naive loop would emit the same `
This README is longer than the copy stored here. Read the rest on GitHub →