Filter-Triggered Promo Cards Inside the Search Results Grid
A theme-editor block that puts a merchandiser-designed card into the search results grid at a chosen position, and shows it only while a named facet is active on that query.
A shopper who narrows a search has just told you something specific, and Liquid has no dictionary, no set and no way to ask whether a given filter is active. We built a promo block on the search section that reads search.filters, tests membership through a delimited string, and drops a merchandiser-designed card into the grid at a chosen slot — only for shoppers who selected the facet it was built for.
Fact Strip
- Client: Three Ships
- Surfaces: Search, with the same block shape on the collection side
- Templates served: 1 — the search template
- Complexity: Medium
- Attribution: Deploi-authored, inside a vendor theme. The storefront runs Palo Alto 5.8.0 by Presidio Creative, a paid premium theme; the search section and the shared promo-card body are the vendor's code. The promo block on the search section, the facet-trigger test, the grid-slot placement and the trailing pass are ours.
- Status: Live, verified 2026-09-06
- Evidence: Two code artifacts plus three client working records
- Confidence: Strong
- Platform primitives: 3
The Problem
A search result grid is a list. Shopify gives a theme the matched results, the facets the shopper applied and nothing that resembles a merchandising slot, so anything editorial sits above the grid or below it — outside the thing people are actually scanning. Three Ships wanted the opposite: to reach a shopper at the moment they narrow, because narrowing is when a query stops being vague. Someone who filters a search by skin type is a good candidate for the skin quiz in a way that someone who typed three letters into the header is not. What they did not want was a banner hard-coded onto every query, or a developer in the theme for each campaign.
The Constraint
Liquid has no dictionaries, no sets and no membership test. The only signal available for "is this filter active?" is walking search.filters and reading active_values on each one — plus min_value and max_value, because a price range is active without any value being selected. Placement is harder than the trigger. A promo occupies a grid slot rather than floating over one, so the decision has to be made inside the same for loop that renders results, while that loop is running. And the vendor section renders that loop up to four times for one request — an all-types grid, a products-only tab, and a trailing pass behind each — so anything added to it has to behave identically in every pass and render exactly once. On top of that, this is a purchased theme: every mechanism added to a vendor file is a merge conflict deferred.
What We Built
A promo block type on search.liquid. Its merchandiser-facing setting is trigger_filter_label — the filter key exactly as it appears in the URL, skin_type rather than "Skin Type" — plus position from 1 to 48, a width of full, two grid items or one, and a full card design set: image, richtext heading with nine highlight styles, body copy, button, and overlay opacity.
Before the results loop starts, the section walks search.filters and builds one string of active facet keys, section-signs used as delimiters: §skin_type§benefit§. Price ranges are folded into the same string when either bound is set. A promo then renders only if § plus its trigger plus § is contained in that string. That is the whole membership test, and it is the only shape Liquid offers.
Placement runs off a grid_slot counter that increments once per rendered card, not once per iteration. That distinction is the point: products suppressed from this storefront's search results still cost an iteration, and counting iterations would drift a promo away from the position a merchandiser chose. Counting rendered cards keeps it where they put it. A rendered_promo_ids accumulator — block ids wrapped in @ sentinels, the same delimited-string trick — stops a block being emitted twice by two passes over the same results. A trailing pass after the loop picks up any promo whose position landed beyond the number of results, so a tile at slot 12 on a nine-result page still appears rather than vanishing silently.
search-promo-card.liquid is the grid wrapper: the width class and the intersection ratio that drives the theme's scroll animation. It delegates the card body itself to the theme's shared promo-card.liquid, which both the collection and search sections use — so the tile inherits the vendor's card markup rather than forking a second one. Styling lives in override.css under .search-promo-card, which is where local styles for this storefront belong.
Why This Way
The trigger matches the URL parameter key, not the filter's display label. Three Ships runs Canadian and United States markets with translated storefront copy; a facet's label changes between them, and its key does not. Matching on the key means a merchandiser sets the trigger once and it survives translation.
The delimited string is not elegant. It is the standard substitute for a set-membership test in a language that has no sets, and the § delimiters exist for one reason: without them a short key would match inside a longer one, and a promo built for one facet would appear for another.
Here is the cost we took. The block knows only what Liquid knows at render time, which is facet state — it cannot react to the query text or to anything the browser learns after paint. A shopper who types a campaign name and never touches a facet sees the plain grid, and there is nothing in this block that can change that. Second, this lives inside vendor code. Everything the block does depends on the vendor's grid loop staying shaped the way it is, and that is a maintenance obligation we own for as long as the theme is upgraded.
Why Not an App
The in-grid promotional banner is a standard feature of search and merchandising apps, and buying one is a defensible call for a store with more campaigns than this one. What the theme-native version gets you is that the tile is server-rendered in the same markup as the products beside it. It inherits the grid's responsive columns and its animation timing for free, costs no additional request, and cannot flash in after paint — which on a results grid is the difference between a card and an interruption. What we gave up is everything an app knows and Liquid does not: behavioral triggers, per-session targeting and reporting on the tile itself. For a promo whose entire job is "this shopper filtered by skin type," none of that was worth a subscription and a script tag.
Implementation Notes
trigger_filter_labelis normalized withstrip | downcase | replace: " ", "_"before comparison, so a merchandiser typing "Skin Type" still matches theskin_typeparameter.- Promos require
products_count > 0, so a no-results page never renders a promotional card into an empty grid — the worst possible place for one. - The animation delay for a tile is computed from its own slot:
grid_slot | modulo: columns | times: 150, so it animates in with its row rather than on its own schedule. - The first row uses a 0.15 intersection ratio against 0.3 for everything below the fold, so above-the-fold tiles trigger earlier than ones a shopper has to scroll to.
- The same block schema and trigger convention exist on the theme's collection section, which sorts its tiles through a serialized array string; the search implementation is the simpler in-loop variant of the same idea.
- The live configuration on the search template is a single block: one promo triggered on the skin-type facet, placed at position 1, with a button routed to the storefront's skin quiz.
Edge Cases
- A blank
trigger_filter_labeldisables the block instead of showing it on every query — the fail-closed direction, chosen because an untriggered promo is invisible and a mistriggered one is on every page. - Price-range facets count as active when either the minimum or the maximum is set, not only when both are, because a shopper who sets one bound has still narrowed.
- Promos positioned beyond the last result are emitted by the trailing pass rather than dropped, so a tile never disappears because a query returned fewer products than usual.
rendered_promo_idsguards the overlap between the in-loop path and the trailing pass, so a block that both could claim is emitted exactly once.
Platform Primitives Used
- Theme blocks — the promo is a section block, so a merchandiser adds, removes, positions and designs one in the theme editor without a deploy.
- Storefront filtering —
search.filtersand theiractive_valuesare the entire trigger signal; the facets themselves stay Shopify's. - Liquid section schema and settings — carries the trigger key, position, width and the full card design set as typed settings rather than theme code.
Where It Runs
One template. The block lives on the section that renders the whole /search results body, and the live configuration currently runs a single tile. The same block shape exists on this storefront's collection section, so the two surfaces are configured by the same vocabulary even though the placement code differs.
What This Demonstrates
Primary: Collection merchandising: rails, tiles and in-grid promotion — specifically the conditional variant, where a tile is not just placed but qualified. It is also a small, precise instance of filters and faceted navigation: the facets are Shopify's, and what we added is a reader of their state.
How We Know
Two artifacts back this page: the search section and the promo-card wrapper snippet, roughly 200 lines of Liquid between them, plus the block's live configuration on the search template. Three client working records cover the same scope: a search-bar optimization program that also implemented filters sourced from metafields, which is the facet vocabulary these triggers point at. The section carries documented snippet headers and the styles sit in the storefront's own override stylesheet, and those two signals are what make the authorship line here readable rather than inferred.
Related Builds
- Tabbed multi-type search results page with faceted filtering — the section this block renders inside, and the reason placement has to survive four passes.
- Collection page: three filter modes, in-grid promo blocks and infinite scroll — the collection-side twin of the same block vocabulary, where the promo engine is vendor code.
The Buy-vs-Build Question
In-grid promotion is one of the clearest buy-or-build cases in merchandising, because the feature is small and the recurring cost is not. Our reading of when a merchandising rule earns an app and when it earns forty lines of Liquid is in the merchandising rules decision.
Want Your Search Page to Answer Back?
Every filter a shopper applies is a sentence about what they want. Contact us today if your grid is not listening.