Skip to main content

Grid Inline Banners

Non-product content (promo banners, video tiles, editorial blocks) inserted at configurable positions within the product results grid. The widget supports three injection sources, all merged and interleaved at render time.

Architecture Overview

GridApp (Vue)
|
+--------------------+--------------------+
| | |
Admin-configured Metaobject promos Videowise video
(MarqoUIConfig) (Storefront API fetch) (Storefront API)
| | |
v v v
grid_injections __marqoCollectionPromos __marqoVideoCard
UIComponent (window global) (window global)
\ | /
+-------------------+-------------------+
|
injections (computed)
|
interleavedItems (computed)
|
Vue render function

All three sources are normalized into the same { id, position, span, html, css, enabled } shape and spliced into the product list by position.

Source 1: Metaobject Promo Cards (Storefront API)

Collection-level metafields that reference Shopify metaobjects. Fetched client-side via the Storefront API on collection page load.

Data Flow

  1. enrichWithCollectionPromos(collectionHandle) is called on page load (marqo-search-app.ts)
  2. fetchCollectionPromotionalCards() issues a Storefront API GraphQL query against the collection
  3. Parsed cards are cached on window.__marqoCollectionPromos
  4. marqo:promos.ready event fires, triggering GridApp re-evaluation

Metaobject Types

plp_inline_banner

Referenced via the collection metafield global.inline_banner. Supports full responsive configuration with separate desktop/mobile fields.

Metaobject Field KeyTypeRequiredDescription
titleSingle-line textNoBanner headline
textRich textNoBody text (parsed from Shopify rich text JSON)
urlURLNoDesktop link destination
url_mobileURLNoMobile-specific link (falls back to url)
button_textSingle-line textNoCTA button label
button_styleSingle-line textNoRendered as data-button-style attribute for CSS targeting
background_imageFile (image)YesDesktop hero image (MediaImage reference)
background_image_mobileFile (image)NoMobile hero image (falls back to background_image)
title_colorSingle-line textNoHex color for title
text_colorSingle-line textNoHex color for body text
alignmentSingle-line textNoLayout alignment (left, center, right)
alignment_mobileSingle-line textNoMobile-specific alignment
locationIntegerYesGrid position (1-based: 1 = after 1st product)
location_mobileIntegerNoMobile-specific grid position

Required for rendering: background_image and location must both be set or the banner is skipped entirely.

Layout: Always rendered with stacked: true (image behind, text overlay at bottom), unlike the legacy promotional image slots.

Assigning to a collection: In Shopify Admin, go to the collection's metafields and set global.inline_banner to reference a plp_inline_banner metaobject entry.

promotional_image / promotional_image_2 (legacy promo slots)

Two fixed collection metafield namespaces providing up to 2 promo card slots per collection. These use individual metafields rather than a metaobject reference.

Metafield KeyTypeRequiredDescription
titleRich textNoCard headline (parsed from rich text JSON)
subcopyRich textNoCard body text
imageFile (image)YesCard image (MediaImage reference)
urlSingle-line textNoLink destination
button_textSingle-line textNoCTA button label
locationIntegerYesGrid position (1-based)
bg_colorSingle-line textNoBackground color hex
title_colorSingle-line textNoTitle text color hex
subcopy_colorSingle-line textNoBody text color hex
button_styleSingle-line textNoCSS class for button styling
alignmentSingle-line textNoLayout alignment
switch_title_and_subtitleBoolean stringNoSwap title/subcopy display order

Key differences from plp_inline_banner:

  • No mobile-specific fields (position, image, URL, alignment)
  • No stacked layout (side-by-side image + text)
  • Two fixed slots per collection (not a single metaobject reference)

GraphQL Query

The full query issued by fetchCollectionPromotionalCards():

query CollectionPromos($handle: String!) {
collectionByHandle(handle: $handle) {
# Slot 1: promotional_image namespace
promoTitle: metafield(namespace: "promotional_image", key: "title") { value }
promoSubcopy: metafield(namespace: "promotional_image", key: "subcopy") { value }
promoImage: metafield(namespace: "promotional_image", key: "image") {
reference { ... on MediaImage { image { url } } }
}
promoUrl: metafield(namespace: "promotional_image", key: "url") { value }
promoButton: metafield(namespace: "promotional_image", key: "button_text") { value }
promoLocation: metafield(namespace: "promotional_image", key: "location") { value }
promoBgColor: metafield(namespace: "promotional_image", key: "bg_color") { value }
promoTitleColor: metafield(namespace: "promotional_image", key: "title_color") { value }
promoSubcopyColor: metafield(namespace: "promotional_image", key: "subcopy_color") { value }
promoButtonStyle: metafield(namespace: "promotional_image", key: "button_style") { value }
promoAlignment: metafield(namespace: "promotional_image", key: "alignment") { value }
promoSwitch: metafield(namespace: "promotional_image", key: "switch_title_and_subtitle") { value }

# Slot 2: promotional_image_2 namespace (same fields)
promo2Title: metafield(namespace: "promotional_image_2", key: "title") { value }
# ... (identical fields with promo2 prefix)

# Inline banner: global.inline_banner metaobject reference
inlineBanner: metafield(namespace: "global", key: "inline_banner") {
reference {
... on Metaobject {
bannerHandle: handle
bannerTitle: field(key: "title") { value }
bannerText: field(key: "text") { value }
bannerUrl: field(key: "url") { value }
bannerUrlMobile: field(key: "url_mobile") { value }
bannerButtonText: field(key: "button_text") { value }
bannerButtonStyle: field(key: "button_style") { value }
bannerBgImage: field(key: "background_image") {
reference { ... on MediaImage { image { url } } }
}
bannerBgImageMobile: field(key: "background_image_mobile") {
reference { ... on MediaImage { image { url } } }
}
bannerTitleColor: field(key: "title_color") { value }
bannerTextColor: field(key: "text_color") { value }
bannerAlignment: field(key: "alignment") { value }
bannerAlignmentMobile: field(key: "alignment_mobile") { value }
bannerLocation: field(key: "location") { value }
bannerLocationMobile: field(key: "location_mobile") { value }
}
}
}
}
}

Rendered HTML Structure

Metaobject promo cards are rendered by buildPromoCardHtml() in GridApp.ts:

<div class="marqo-grid-injection">
<a href="/collections/routines" class="marqo-promo-card-link">
<div class="marqo-promo-card marqo-promo-card--stacked"
data-promo-handle="spring-routine-banner"
style="--promo-title-color: #fff; --promo-alignment: center">
<picture>
<source media="(max-width: 768px)" srcset="https://cdn.shopify.com/.../mobile.jpg" />
<img class="marqo-promo-card-image"
src="https://cdn.shopify.com/.../desktop.jpg"
alt="Complete Your Routine" loading="lazy" />
</picture>
<div class="marqo-promo-card-overlay">
<div class="marqo-promo-card-title">Complete Your Routine</div>
<div class="marqo-promo-card-subcopy">Shop our curated sets</div>
<span class="marqo-promo-card-button">Shop Routines</span>
</div>
</div>
</a>
</div>

Responsive URLs: When url_mobile differs from url, two <a> wrappers are rendered with CSS classes marqo-promo-link-desktop / marqo-promo-link-mobile for show/hide at breakpoints.

CSS Custom Properties

Metaobject field values are mapped to CSS custom properties on the .marqo-promo-card element:

FieldCSS Variable
bg_color--promo-bg-color
title_color--promo-title-color
subcopy_color / text_color--promo-subcopy-color
alignment--promo-alignment
alignment_mobile--promo-alignment-mobile

Source 2: Admin-Configured Grid Injections

Static HTML/CSS blocks configured per-shop in the Marqo admin UI. Stored in the grid_injections UIComponent.

Configuration

Admin UI: Settings > Grid Injections > Add Content Block

SettingTypeDefaultDescription
PositionInteger0Where to insert (0 = before first product, 4 = after 4th product)
Column SpanInteger (1-4)1Grid columns to span. Clamped to current column count at each breakpoint via span min(N, var(--marqo-grid-columns)).
HTMLCode editorRaw HTML content
CSSCode editorScoped styles
EnabledToggletrueShow/hide this injection

Backend Model

{
"grid_injections": {
"name": "Grid Injections",
"enabled": true,
"value": {
"items": [
{
"id": "spring-promo",
"position": 4,
"span": 4,
"html": "<div class='promo'>Spring Sale</div>",
"css": ".promo { text-align: center; }",
"enabled": true
}
],
"videowise_enabled": false
}
}
}

HTML Sanitization

Admin-authored HTML goes through sanitizeInjectionHtml() which strips:

  • Inline event handlers (onclick, onerror, etc.)
  • javascript:, data:, vbscript: URIs in href/src/action/formaction/srcdoc
  • <script>, <iframe>, <object>, <embed>, <form> tags

CSS Scoping

Injection CSS is injected into <style id="marqo-grid-injection-styles">. Scope selectors to avoid page-wide side effects:

/* Good */
.marqo-grid-injection .my-banner { padding: 24px; }

/* Bad */
.banner { padding: 24px; }

Source 3: Videowise Inline Video

Requires videowise_enabled: true in the grid_injections UIComponent. Fetches from the promotional_video collection metafield namespace.

Metafield KeyDescription
main_video_codeVideowise embed HTML (script tags extracted and loaded separately)
main_video_desktop_locationDesktop grid position
main_video_mobile_locationMobile grid position
main_product_variant_selectProduct variant ID for overlay pricing

Grid Rendering Pipeline

Position Semantics

SourcePosition fieldIndexingConversion to 0-based
Admin-configuredposition0-basedUsed as-is
Metaobject promolocation / location_mobile1-basedposition - 1
Videowise videomain_video_desktop_location1-basedposition - 1

Splice Algorithm (GridApp.ts)

  1. Start with product cards as the base array
  2. Collect all enabled injections from all three sources
  3. Sort by position ascending
  4. Splice each injection into the array at its position — earlier insertions shift later indices, which provides the correct offset for subsequent splices
  5. Positions exceeding the product count are clamped to the end; negative positions are clamped to 0

Mobile Responsiveness

  • Breakpoint: 768px (hardcoded window.matchMedia)
  • When the viewport crosses the breakpoint, promoGeneration increments, forcing the injections computed property to re-evaluate
  • Metaobject banners swap to their *Mobile field variants (position, image, URL, alignment)
  • Admin-configured injections have no per-breakpoint fields — they keep the same position but their span is clamped via CSS span min(N, var(--marqo-grid-columns))

DOM Events

EventWhenPayload
marqo:promos.readyAfter Storefront API fetch completes
marqo:grid.injectedAfter render when injections are present{ injections: [...] }

Third-party scripts (Videowise, Visually) can listen to marqo:grid.injected to hydrate containers after injection.

Key Source Files

FileWhat
storefront_search/src/storefront.ts:343-505CollectionPromoCard interface, parsers, GraphQL query
storefront_search/src/collection-promos.tsenrichWithCollectionPromos() orchestrator
storefront_search/src/vue/GridApp.ts:43-97buildPromoCardHtml() — HTML construction
storefront_search/src/vue/GridApp.ts:200-275Injection merging + interleave splice logic
storefront_search/src/vue/GridApp.ts:441-473Vue render function
storefront_admin/app/components/settings/grid-injections-section.tsxAdmin UI configuration
storefront_admin/app/lib/types.ts:228-240GridInjectionItem / GridInjectionsSettings types
admin_server/admin_server/models/ui_settings.py:497-504Backend UIComponent defaults