Designing for Scale
Structuring components for very large sites.
An Elementor widget for post archives that stay on one page. Visitors type, filter and page through your content while the grid re-renders in place — and each card can be a saved Elementor template of your own design.
What the widget is, what it needs, and how to get a working grid on a page.
The panel below runs on a loop: a query is typed out, the filter drawer opens, a category is picked, and the results settle. Nothing here talks to a server — it is a scripted mock of the real interaction, included so you can see the rhythm before installing anything.
Structuring components for very large sites.
Debounced requests that never block the UI.
What shipped across the platform this week.
Tokens, primitives, and consistent UI at scale.
Caching and query tuning for 10,000+ posts.
The five updates worth your time today.
Most post-grid widgets stop at "show me ten posts in three columns". This one is built for the archive that has to carry a real content library: tens of thousands of entries, a dozen ways to narrow them down, and an editor who wants the card to look like the rest of the site. Posts, pages and custom post types all render the same way.
Waits 300 ms after the last keystroke, then queries. Whatever you have filtered stays filtered.
Numbered links, a Load More button, or a scroll sentinel that fetches the next batch.
Slides in from either edge so the grid keeps the full width. Active choices show as removable chips.
Taxonomies, author, date and post type out of the box; any ACF field found on the site joins the list.
Point the widget at a saved template and every post renders through it, dynamic tags resolved per post.
Responses cache under a namespaced transient generation, and counting is skipped where it is not needed.
Query arguments are re-read from the saved document server-side, so a tampered request cannot widen them.
Where the settings live There is no admin screen and no shortcode to memorise. Everything is a control on the widget itself, so the configuration travels with the page it belongs to. Activate, then drag Savvy Posts Grid off the widget panel.
| Requirement | Minimum | Notes |
|---|---|---|
| WordPress | 5.8 | Tested up to 6.8 |
| PHP | 7.4 | Uses strict_types; PHP 8.x fully supported |
| Elementor | 3.5.0 | Free version is enough; tested up to 3.21 |
| Elementor Pro | — | Optional. Pro templates & dynamic tags fully supported when present |
| ACF / ACF Pro | — | Optional. Fields are auto-detected and added to the filter builder |
Browser support Anything current. The front-end script leans on IntersectionObserver for infinite scroll and AbortController to cancel superseded requests, so IE11 is out of scope.
savvy-posts-grid folder into /wp-content/plugins/, or hand the ZIP to Plugins → Add New → Upload Plugin.plugins_loaded; if PHP or Elementor is too old you get an admin notice rather than a fatal error.spgrid_version for future migrations and flushes rewrite rules.CacheManager::flush_all().spgrid_version and spgrid_cache_generation options and every spgrid_* transient (multisite-aware). Widget settings stored in Elementor documents are preserved so re-activation restores everything.Six steps, and none of them involve leaving the Elementor editor.
They compose The three interactions share one request. A search does not clear your filters, and moving to page two does not clear either of them.
Every control in the Elementor panel, section by section, with its setting key and default.
Everything that decides which posts land in the grid lives here. Worth knowing up front: when an AJAX request arrives, the server ignores whatever the browser claims these settings are and re-reads them from the saved Elementor document.
| Source | Description |
|---|---|
| Custom Query default | Build a query from post types, include/exclude categories, tags, and authors. |
| Manual Selection | Comma-separated post IDs, displayed in the exact order you enter them (spgrid_manual_ids). |
| Specific Posts | Comma-separated post IDs (spgrid_specific_ids), ordered by your Order By setting. |
| Related Posts | Posts sharing terms with the current post, by a chosen taxonomy (spgrid_related_taxonomy, default category). |
| Featured (Sticky) | Sticky posts only. |
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_source | SELECT | query | Content source (see table above). |
spgrid_post_type | SELECT2 | ['post'] | One or more public post types. |
spgrid_per_page | NUMBER | 9 | Posts per page (1–100). |
spgrid_orderby | SELECT | date | date · title · menu_order · rand · comment_count · modified · meta_value · meta_value_num. |
spgrid_orderby_meta | TEXT | — | Meta key, shown when ordering by meta value. |
spgrid_order | SELECT | DESC | Sort direction (ASC / DESC). |
spgrid_include_categories | SELECT2 | — | Limit to these categories (Custom Query only). |
spgrid_exclude_categories | SELECT2 | — | Exclude these categories (Custom Query only). |
spgrid_include_tags | SELECT2 | — | Limit to these tags (Custom Query only). |
spgrid_include_authors | SELECT2 | — | Limit to these authors (Custom Query only). |
spgrid_specific_ids | TEXT | — | Comma-separated IDs (Specific Posts source). |
spgrid_manual_ids | TEXT | — | Comma-separated IDs, kept in order (Manual source). |
spgrid_related_taxonomy | SELECT | category | Taxonomy used for Related Posts. |
For developers The assembled argument array is handed to spgrid/query/args immediately before WP_Query runs, which is the place to bolt on anything the controls do not cover.
Three arrangements, each with its own column and gap values per breakpoint. Grid is the even-height default; List puts the image alongside the text; Masonry lets cards keep their natural heights and flow into columns.
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_layout_mode | SELECT | grid | Layout mode: grid, list, masonry. Applies prefix class spgrid-layout--*. |
spgrid_columns | RESPONSIVE | 3 / 2 / 1 | Columns 1–6 (desktop / tablet / mobile). Exposed as CSS var --spgrid-columns. |
spgrid_gap | SLIDER | 24px | Gap between items (--spgrid-gap). |
spgrid_equal_height | SWITCHER | yes | Stretch cards to equal heights (grid mode only). |
spgrid_card_align | CHOOSE | left | Card content alignment: left / center / right. |
The field waits 300 ms after you stop typing before it queries, so a fast typist produces one request rather than ten. Each new keystroke aborts whatever request is still in flight. Searching drops you back to page one but leaves your filters untouched.
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_enable_search | SWITCHER | yes | Show the search box. |
spgrid_search_placeholder | TEXT | Search posts… | Input placeholder text. |
spgrid_show_search_label | SWITCHER | yes | Show a label above/next to the input. |
spgrid_search_label_text | TEXT | Search | Label text. |
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_show_title | SWITCHER | off | Show a heading above the grid. |
spgrid_title_text | TEXT | page title | Heading text (falls back to the current page title). |
spgrid_title_tag | SELECT | h2 | HTML tag: h1–h6, p, span, div. |
Pick whichever suits the archive. All three fetch over AJAX and carry the current search term and filter selection with them.
| Type | Behavior |
|---|---|
| Numbered default | Ordinary page links. The grid is swapped out and the page eases back up to the top of the results. |
| Load More | A button appends the next batch instead of replacing what is there, and removes itself once the last page has been reached. Total-row counting is skipped, which is measurably cheaper on large tables. |
| Infinite Scroll | An invisible sentinel sits below the grid; when it comes within 300 px of the viewport the next batch is already being fetched, so the seam rarely shows. |
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_enable_pagination | SWITCHER | yes | Enable pagination. |
spgrid_pagination_type | SELECT | numbered | numbered · load_more · infinite. |
spgrid_load_more_text | TEXT | Load More | Button label (Load More type only). |
Filters live in a drawer rather than a sidebar, so the grid keeps the page width. You describe each one as a row in a repeater; on the server those rows are folded into a single tax_query and meta_query rather than one query per filter.
| Control | Type | Default | Description |
|---|---|---|---|
spgrid_enable_filters | SWITCHER | yes | Enable the filter system. |
spgrid_filters_position | SELECT | left | Slide-in side: left or right. |
spgrid_filters_button_text | TEXT | Filters | Toolbar button label. |
spgrid_filters_reset_text | TEXT | Reset | Reset button label. |
spgrid_filters_apply_text | TEXT | Apply | Apply button label. |
Clearing a label restores its default These four text fields treat blank as “use the default” rather than rendering an empty button, so emptying one is how you undo an override. Note that a widget saved before a default changed keeps the old value — clear the field to pick up the new one.
spgrid_filters repeaterOne row becomes one collapsible group in the drawer. A Category row is present by default, so a freshly dropped widget already has a working filter — the toolbar's filter button only appears once at least one row exists.
| Field | Options | Description |
|---|---|---|
filter_source | taxonomies, author, date, date_range, post_type, acf:* | What to filter by (see sources below). |
filter_label | free text | Optional label; defaults to the source's name. |
filter_display | dropdown · checkbox · radio | How options are rendered. |
filter_logic | OR (default) · AND | With OR, ticking two boxes widens the results; with AND, a post must carry both to survive. |
category, post_tag, customs…), keyed by taxonomy name.author — all published authors.date — month buckets (YYYYMM) covering the last 24 months.date_range — From/To date inputs for a published-date range.post_type — filter across the widget's selected post types.is-open class); close via the ✕ button, overlay click, or Escape. Background scrolling is locked with body.spgrid-noscroll.is-collapsed, with aria-expanded).Install ACF and its fields simply show up in the filter source list, prefixed acf: — acf:project_status, and so on. Nothing needs registering. The field's own type decides what the filter looks like and how it is matched, so a date picker becomes a range and a checkbox becomes a choice list.
| ACF Field Type | Filter Input | Matching |
|---|---|---|
select, checkbox, radio, button_group | Choice list (dropdown / checkbox / radio) | Exact value match against the field's defined choices. |
true_false | Yes / No choices | Boolean (1/0). |
taxonomy | Term list | Resolves selected term IDs. |
post_object, relationship, page_link | Post list (max 200) | Matches stored post IDs. |
user | User list | Matches stored user IDs. |
text, textarea, email, url, wysiwyg, color_picker | Text input | LIKE contains-search. |
number, range | Min / Max inputs | Numeric comparison (min:X / max:Y). |
date_picker, date_time_picker, time_picker | From / To date inputs | Date range comparison (Ymd; from:DATE / to:DATE). |
spgrid_acf_fields_map_v2 transient.spgrid_acf_vals_{hash}).acf/update_field_group, trash, untrash, delete).The built-in card is a starting point, not a ceiling. Design the card as a normal Elementor template, wire its text and images to dynamic tags, then point the widget at it — the template is repeated once per post with that post's data behind the tags.
| Control | Default | Description |
|---|---|---|
spgrid_template_id | 0 (built-in) | Template used for each card. 0 uses the built-in card. |
spgrid_empty_template_id | 0 | Template shown when no posts match. 0 shows the text message instead. |
spgrid_empty_text | No posts found. | Plain-text empty state (when no empty template selected). |
Before each card is rendered the loop context is made real: setup_postdata() runs, the global $post is swapped, and in_the_loop is set. Only then is the template rendered. The effect is that a dynamic tag behaves exactly as it would inside a Theme Builder loop — it has no way to tell the difference.
Which template type Any saved template will do, though section and container templates behave best as a repeating card. Pro is not a requirement — if it happens to be installed, its dynamic tags work here too.
Leave spgrid_template_id at 0 and you get the bundled card instead. Every part of it — image, title, meta, excerpt, custom fields, button — can be switched off or moved, which covers most archives without opening the template editor at all.
| Control | Default | Description |
|---|---|---|
spgrid_show_image | yes | Featured image (size large, lazy-loaded). |
spgrid_image_position | top | Image above or below the text content. Grid/masonry only — List layout always places the image in its side column. |
spgrid_show_categories | yes | Category links (max 2 terms; falls back to the first public taxonomy if the post has no categories). Rendered as a badge over the featured image, or inline in the meta row when the card has no image. |
spgrid_show_meta | yes | Publication date. |
spgrid_date_format | m.d.y | PHP date format (rendered via date_i18n()). |
spgrid_show_author | off | Author in the meta row — the real post author, linked to their archive. |
spgrid_author_prefix | By | Short prefix shown before the author name. |
spgrid_author_custom | — | Custom static name (e.g. "Editorial Team") shown on every card instead of the post author; rendered as plain text, no link. |
spgrid_show_excerpt | off | Excerpt text. |
spgrid_excerpt_length | 20 | Excerpt length in words (1–100). |
spgrid_show_read_more | off | "Read More" link. |
spgrid_read_more_text | Read More | Read More label. |
Add rows to spgrid_card_acf_fields to surface field values on the card itself — they render between the excerpt and the Read More link. Each row is one value:
| Field | Description |
|---|---|
acf_field | Pick any auto-detected ACF field, or choose Custom meta key… to use raw post meta. |
acf_custom_key | The meta key (shown when Custom meta key… is selected). |
acf_label | Optional label rendered before the value (e.g. "Location:"). |
get_field(), so return formats are honoured and a relationship or user field prints a name rather than an ID. Anything array-shaped is joined with commas..spgrid-card__acf > .spgrid-card__acf-item > .spgrid-card__acf-label + .spgrid-card__acf-value.Every element carries a number, and the card sorts by it — spgrid_order_category, spgrid_order_author, spgrid_order_date, spgrid_order_title, spgrid_order_excerpt, spgrid_order_acf and spgrid_order_read_more. It is the CSS order property underneath, so moving the date above the title costs a number change and no markup.
<article class="spgrid-card">
<div class="spgrid-card__body">
<div class="spgrid-card__meta">
<span class="spgrid-card__author">
<span class="spgrid-card__author-prefix">By</span> <a href="…">Author Name</a>
</span>
<time class="spgrid-card__date">06.05.26</time>
</div>
<h3 class="spgrid-card__title"><a href="…">Post Title</a></h3>
<p class="spgrid-card__excerpt">Excerpt text…</p>
<ul class="spgrid-card__acf">
<li class="spgrid-card__acf-item">
<span class="spgrid-card__acf-label">Location:</span>
<span class="spgrid-card__acf-value">Lahore</span>
</li>
</ul>
<a class="spgrid-card__more" href="…">Read More →</a>
</div>
<div class="spgrid-card__thumb">
<a class="spgrid-card__thumb-link" href="…" tabindex="-1" aria-hidden="true">
<img src="…" alt="…" loading="lazy">
</a>
<span class="spgrid-card__terms spgrid-card__terms--badge">
<a href="…">Category</a><span class="spgrid-card__sep">|</span><a href="…">Category</a>
</span>
</div>
</article>
Copy templates/card-default.php from the plugin into your theme as yourtheme/spgrid/card.php to fully customize the built-in card markup.
Every visual aspect is controllable from Elementor's Style tab.
| Section | What you can style |
|---|---|
| Cards | Background, border, radius, shadow, padding, content spacing; title typography + colors (normal/hover); meta (category, author, date, separator) typography + colors; excerpt; ACF fields (typography, label/value colors, row spacing); read-more button (colors, backgrounds, border, padding, radius, full-width); image aspect ratio (16/9, 4/3, 3/2, 1/1, auto), hover zoom, opacity; hover lift (default 6 px) and hover shadow. |
| Empty State | Typography, color, alignment, padding. |
| Toolbar | Heading typography/color/spacing; search box width, alignment, typography, border, and six color slots (text, background, label, icon, border, focus) per mode; icon buttons (size, radius, normal/hover/active colors and backgrounds). |
| Filters | Panel typography, text/background/title colors, panel width, overlay color; active chips (text/bg/border); section labels and option rows (typography, colors, checkbox accent); footer Apply/Reset buttons. |
| Pagination & Load More | Typography; text, background, active, hover, and border colors per mode; radius; gap; Load More padding. |
| Group | Control IDs |
|---|---|
| Card surface | spgrid_card_bg, spgrid_card_border, spgrid_card_radius, spgrid_card_shadow, spgrid_card_padding, spgrid_card_content_gap |
| Card title | spgrid_card_title_typo, spgrid_card_title_color, spgrid_card_title_hover_color |
| Category badge | spgrid_card_terms_typo, spgrid_card_terms_color, spgrid_card_terms_hover_color, spgrid_card_terms_bg, spgrid_card_sep_color, spgrid_card_terms_border, spgrid_card_terms_padding, spgrid_card_terms_radius, spgrid_card_terms_position (which corner), spgrid_card_terms_offset |
| Card meta | spgrid_card_meta_typo, spgrid_card_author_color, spgrid_card_author_hover_color, spgrid_card_date_color |
| Card ACF fields | spgrid_card_acf_typo, spgrid_card_acf_label_color, spgrid_card_acf_value_color, spgrid_card_acf_gap |
| Read More button | spgrid_card_more_typo, spgrid_card_more_color, spgrid_card_more_hover_color, spgrid_card_more_bg, spgrid_card_more_hover_bg, spgrid_card_more_border_color, spgrid_card_more_hover_border_color, spgrid_card_more_border, spgrid_card_more_padding, spgrid_card_more_radius, spgrid_card_more_width |
| Card image | spgrid_card_image_ratio, spgrid_card_image_hover_zoom, spgrid_card_image_opacity |
| Card hover | spgrid_card_hover_transform, spgrid_card_hover_shadow |
| Search | spgrid_search_width, spgrid_search_align, spgrid_search_typo, spgrid_search_color, spgrid_search_bg, spgrid_search_label_color, spgrid_search_icon_color, spgrid_search_border_color, spgrid_search_focus_color, spgrid_search_padding (default 12px 40px 12px 40px) |
| Icon buttons | spgrid_iconbtn_size, spgrid_iconbtn_radius, spgrid_iconbtn_color/_bg/_border_color with hover/active variants |
| Filter panel | spgrid_filter_typo, spgrid_filter_color, spgrid_filter_panel_bg, spgrid_filter_title_color, spgrid_filter_panel_width, spgrid_filter_overlay_color |
| Chips | spgrid_chip_color, spgrid_chip_bg, spgrid_chip_border_color |
| Filter buttons | spgrid_filter_btn_typo, spgrid_filter_apply_color, spgrid_filter_apply_bg, spgrid_filter_reset_color, spgrid_filter_reset_border_color, spgrid_filter_btn_padding, spgrid_filter_btn_radius |
| Toolbar filter button | spgrid_filter_toggle_typo, spgrid_filter_toggle_label_color |
| Pagination | spgrid_pagination_typo, spgrid_pagination_color, spgrid_pagination_bg, spgrid_pagination_active_color, spgrid_pagination_active_bg, spgrid_pagination_hover_bg, spgrid_pagination_border_color, spgrid_pagination_radius, spgrid_pagination_gap, spgrid_loadmore_padding |
The AJAX contract, hooks, front-end behaviour, caching model and security posture.
Search, filtering and paging are all the same call. One endpoint is registered twice — wp_ajax_spgrid_query for signed-in users and wp_ajax_nopriv_spgrid_query for everyone else — and it returns rendered HTML rather than raw posts, so the card design stays on the server.
action: 'spgrid_query'
nonce: // CSRF token, verified against 'spgrid_nonce'
post_id: // int — the page/document containing the widget
widget_id: // string — the Elementor element ID
search: // string — search term
paged: // int ≥ 1 — page number
filters: // object — filters[filter_key][] = value…
{
"success": true,
"data": {
"html": "<div class=\"spgrid-grid__item\">…</div>…",
"empty_html": "<p class=\"spgrid-empty\">No posts found.</p>",
"pagination": "<ul class=\"spgrid-pagination__list\">…",
"has_more": false, // load_more / infinite only
"total_pages": 3, // numbered only
"current": 1,
"found": 27, // numbered only
"is_empty": false,
"cached": false // true when served from transient cache
}
}
post_type or per_page in the payload changes nothing.cached flag in the payload tells you which happened.Five extension points, all namespaced under spgrid/. Between them you can reshape any query, swap the fallback thumbnail, and decide whether and for how long a result is cached.
Fires once boot has finished and every service is wired up. The Plugin instance comes with it, which makes this the right moment to hang your own integration off the container.
The last stop before WP_Query runs. You get the assembled $args, the widget's $settings, and the $request state describing the current search term, page and filter selection — return the array you want executed.
The thumbnail used for posts with no featured image. Receives the bundled SVG's URL and the post ID. Return your own URL to swap it, or an empty string to drop the image slot entirely on those posts. Anything that is not a .svg is treated as a photo and cropped to fill rather than centred as a glyph.
Return false to bypass the cache entirely. Editor and preview requests already skip it, so this is for production cases — a personalised grid, for instance.
How long a cached response stays valid, in seconds. Five minutes by default; raise it for archives that rarely change.
add_filter( 'spgrid/query/args', function ( $args, $settings, $request ) {
// Always hide the "internal" category from every grid.
$args['category__not_in'][] = 42;
return $args;
}, 10, 3 );
// Cache AJAX responses for 15 minutes instead of 5.
add_filter( 'spgrid/cache/expiry', fn() => 15 * MINUTE_IN_SECONDS );
// Disable caching entirely while developing.
add_filter( 'spgrid/cache/enabled', '__return_false' );
The front-end script is plain JavaScript with no jQuery dependency. It constructs one Grid object per .spgrid-wrap on the page, each holding its own search, paged and filters state — so two grids on the same page never interfere with one another.
SPGRID_CONFIG){
ajaxUrl: "https://yoursite.com/wp-admin/admin-ajax.php",
nonce: "…", // wp_create_nonce('spgrid_nonce')
action: "spgrid_query",
debounce: 300, // search debounce in ms
i18n: {
loading: "Loading…",
noResults: "No posts found.",
error: "Something went wrong…",
loadMore: "Load More"
}
}
| Behavior | Mechanism |
|---|---|
| Typing | Queries 300 ms after the last keypress; adjust with SPGRID_CONFIG.debounce. Enter never submits a surrounding form. |
| Overlapping requests | A new request aborts the previous one, so a slow response can never overwrite a newer result. |
| Infinite scroll | An observer watches .spgrid-infinite__sentinel and fires 300 px early. |
| Loading states | aria-busy flips on the results region; the overlay, the search spinner and .spgrid-is-loading give you three separate styling hooks. |
| Failures | A dropped connection or expired nonce leaves a readable message in place of the grid, not an empty box. |
| Injected widgets | Elementor handlers are re-run against newly inserted cards so their own scripts come alive. |
The same search, on the same filters, on the same page, should not cost a second query. Responses are stored under a namespaced transient key; invalidation works by moving the namespace rather than hunting down individual entries.
spgrid_{group}_{generation}_{hash}
│ │ └─ MD5 of the normalized (ksort'd) request args
│ └─ incrementing generation counter (bumped on content changes)
└─ logical group, e.g. "ajax"
The hash covers the document ID, the widget ID and the entire request state. If Polylang is running, the active language joins the key too, so translations never serve each other's cards.
| Cache | TTL | Invalidated by |
|---|---|---|
| AJAX responses | 5 min (filterable via spgrid/cache/expiry) | Generation bump on save_post, deleted_post, edited_terms, created_term |
| ACF field map | 1 hour | ACF field group save / trash / untrash / delete |
| ACF distinct values | 10 min | Natural expiry |
DELETE … LIKE across the options table on every save.\Spgrid\Cache\CacheManager::flush_all(). Deactivating the plugin does the same thing.These names are part of the plugin's contract and will not move between releases, so it is safe to hang your own CSS off them. The custom properties below are the cheaper hook where one exists — set the variable and every rule using it follows.
| Class | Element |
|---|---|
.spgrid-wrap | Widget root (also carries .spgrid-is-loading). |
.spgrid-layout--grid|list|masonry | Layout modifier on the wrapper. |
.spgrid-results | Results region (aria-busy during AJAX). |
.spgrid-grid / .spgrid-grid__item | The grid (role="list") and its items. |
.spgrid-card__thumb / .spgrid-card__thumb-link | Media slot and the image's own (decorative) link. The slot is the positioning context for the badge. |
.spgrid-card__terms--badge | Category badge over the image. Corner offsets read the --spgrid-badge-offset custom property. |
.spgrid-card + __body, __meta, __terms, __sep, __author, __date, __title, __excerpt, __acf, __acf-item, __acf-label, __acf-value, __more, __thumb | Built-in card and its parts. |
.spgrid-empty / .spgrid-error | Empty state / error message. |
.spgrid-filters (+ .is-open) | Off-canvas panel (role="dialog"). |
.spgrid-filters__toggle | Filters toolbar button. |
.spgrid-filter__head (+ .is-collapsed) | Accordion section header. |
.spgrid-pagination / .spgrid-page[data-page] | Numbered pagination and page links. |
.spgrid-loadmore__btn[data-next-page] | Load More button. |
.spgrid-infinite__sentinel | Infinite-scroll trigger element. |
.spgrid-loader / .spgrid-search__spinner | Loading indicators. |
body.spgrid-noscroll | Scroll lock while the filter panel is open. |
| Variable | Purpose |
|---|---|
--spgrid-columns | Column count on .spgrid-grid (responsive). |
--spgrid-gap | Gap between grid items. |
--spgrid-panel-bg | Filter panel background. |
--spgrid-search-ml / --spgrid-controls-ml | Search/controls alignment helpers. |
<time> around dates, <nav> around pagination, <aside> for the drawer.aria-busy, aria-expanded and aria-current so a screen reader hears results arrive.The guiding assumption is that the request is hostile. Nothing the browser sends is allowed to decide what gets queried — it can only say which page, which search term, and which of the filters the widget already declared.
| Layer | Implementation |
|---|---|
| CSRF | A spgrid_nonce is minted with the page and checked on every request. No nonce, no query — the handler returns 403 before touching the database. |
| Settings isolation | Settings come from the saved document, not the payload, so the request has no vocabulary for widening the query. |
| Input sanitisation | sanitize_text_field() for search and IDs, absint() for numbers, an allow-list for orderby and order, and deep sanitisation of the filter array followed by re-validation against the widget's declared filters. |
| Scope limits | Post types are intersected with what the widget allows, taxonomies must actually exist, and only published posts are returned — drafts and private posts appear solely for users who could already edit them. |
| Output escaping | Escaping happens at the point of output rather than on the way in, JSON goes out through the core helpers, and any author-supplied CSS selector or colour is filtered to a safe character set first. |
| Capabilities | The admin notices about missing requirements are only rendered for users who could act on them. |
Common questions and the release history.
templates/card-default.php into yourtheme/spgrid/card.php; the theme copy wins.\Spgrid\Cache\CacheManager::flush_all() or filter spgrid/cache/enabled to false.spgrid/query/args runs immediately before the query executes and hands you the arguments, the widget settings and the live request state. Hooks & Filters has worked examples.savvy-posts-grid text domain.spgrid_ control prefix, Spgrid\ namespace, savvy-posts-grid text domain, spgrid-* CSS classes, spgrid_query AJAX action, and spgrid/* hooks.