Collection Grid Card on a Licensed Theme: Metafield Merchandising, a Badge Precedence Chain, a Hover-Swap Swatch Element and First-Row Loading Hints
Palo Alto's grid item extended so the card's title, strapline, cutline and badge come from product metafields, badges resolve in one fixed order, color options become a swatch element that swaps the card image on hover, and column classes are computed from whatever section happens to be calling.
One product-card snippet on Three Ships is rendered by collection grids, carousels, tab sections, search and a Section Rendering API endpoint, each with its own column count. We extended Palo Alto 5.8.0's grid item so merchandising copy comes from four product metafields, badges resolve through one precedence chain, color options render as a product-grid-item-swatch element that swaps the card image on hover, and the first three rows on collection templates carry eager loading hints.
The Problem
Shopify's product object gives a card a title, a featured image, a price and a compare-at price. A skincare listing page asks the card to carry more than that: a shorter display name than the SEO title, a one-line benefit strapline, a colored descriptor, a custom badge that isn't "sale" or "sold out", and a set of shade or color chips. None of those has a home in the product record until you give it one, and none of them can be a hard-coded string in the theme without turning every merchandising change into a deploy.
The card is also one snippet with many callers. Collection grids render it at four columns on desktop and two on mobile; carousels, tab sections and look sections call it with different counts; search calls it; the Section Rendering API endpoint calls it with no section context at all. A card with layout classes hard-coded for one of those callers is wrong for the rest.
The Constraint
The theme is Palo Alto 5.8.0 by Presidio Creative, a licensed premium theme that keeps upgrading. Every change had to live inside the vendor's product-grid-item.liquid as a narrow extension, not a fork, so a theme update remains a merge rather than a rewrite. The layout arithmetic had to be computed rather than declared — from columns, columns_mobile and the loop index — because the caller decides the grid and the card can't know in advance which caller it has.
Merchandising had to be per product and editable in the admin, which meant metafields, and the card had to render sensibly when they're empty, because a catalog is never fully filled in. The swatch axis couldn't be the English word "Color": the storefront is translatable, so which option is the color option has to come from the locale file. And Shopify's own option-value swatches had to be supported alongside the older approach of pulling a swatch image from the first matching variant, because the catalog uses both.
What We Built
Four metafields drive the card's copy. custom.short_product_title overrides the card title when present; custom.product_mini_description renders as a one-line benefit strapline; theme.cutline is a colored descriptor beneath the name; theme.badge is a single-line custom badge. Each falls back silently — no metafield, no element — so an unfilled product renders as the vendor's default card rather than as a card with holes in it.
Badges resolve through one precedence chain, evaluated once per card: a custom theme.badge wins; otherwise sold out; otherwise sale; otherwise a computed saving badge. The saving badge is compare_at_price | minus: price | times: 100 | divided_by: compare_at_price | round, rendered as a percentage — with a separate "up to" string when the discount varies across the product's variants, so a card never promises a saving only one size gets.
Color options become a product-grid-item-swatch custom element. Detection is locale-driven: the general.swatches.color translation string is normalized and matched against each option.name | handle, so the swatch axis follows the locale rather than a hard-coded English name. For each option value the snippet walks product.variants for the first variant with that value, captures its featured media at image_url: width: 600 with the &width=600 query stripped, and writes data-swatch-image and data-swatch-variant onto the chip, which is what the element reads to swap the card image on hover. Shopify's native option.swatch.image and option.swatch.color are supported alongside that variant walk, so a catalog can carry both. More than five swatches hides everything past the fourth behind is-hidden.
Layout classes are computed. grid-item--column-N, grid-item--even, grid-item--second-tablet and grid-item--third-tablet are derived from item_index, columns and columns_mobile with modulo arithmetic, so a four-column grid, a three-up carousel and a two-column mobile view all get correct wrapping from one snippet.
On collection templates, the first products_per_row_on_desktop * 3 cards carry loading="eager" and fetchpriority="high"; every card after them stays lazy. products_per_row_on_desktop is a theme setting rather than the calling section's, so the count is the store's default desktop grid width times three — the first three rows of a default collection page — and the whole branch is confined to template.name == 'collection' so a carousel on the home page doesn't inherit it.
api-product-grid-item.liquid, an eight-line section, exposes the card through the Section Rendering API for callers that fetch cards by ?section_id=, with ||itemAosDelay|| and ||itemAosAnchor|| placeholder tokens the vendor theme's JavaScript substitutes client-side. product-grid-item--onboarding.liquid and product-card-placeholder.liquid are the theme-editor variants rendered when a section has no products configured.
Why This Way
Computing the grid classes from the caller's settings is what keeps one snippet valid everywhere. The alternative — a card per section type — is how a theme ends up with five cards that agree on Tuesday and drift by Friday. The metafields exist for the same reason from the merchandiser's side: a per-product override the admin can edit is a merchandising change; a string in the snippet is a deploy.
The precedence chain fixes a rule in one place. A custom badge always beats a sale badge, which always beats a computed saving. That's a merchandising decision frozen in Liquid, and it's the cost of the approach: a merchandiser who wants "sale" to show on a product that also carries a custom badge can't have both without a theme change. The eager-loading scope is a second decision of the same kind — three rows of the store's default desktop width, fixed once in Liquid, and right until the grid's design changes and nobody revisits it.
And the four metafields are four more fields to fill on every product. The fallbacks mean an unfilled product doesn't break, but they also mean it looks like a vendor default card, and nothing tells the merchandiser which products are which.
Why Not an App
Badge and label apps and swatch apps both solve their half of this. The usual mechanism is a script that reads the rendered grid after load and rewrites the cards — a label injected into the image corner, a swatch row appended under the title — from rules held in the app's own database.
The consequence that decided this is where the rule lives. A badge held in an app's database is a rule about a product that isn't on the product: a merchandiser edits it in a second admin, and nothing in the product record says it exists. Resolving badges and swatches in Liquid from metafields and option values puts the rule on the product and the markup in the response the server sends. Shopify's own option-value swatches now cover the ordinary case without an app at all, and the card supports them alongside the older approach.
Implementation Notes
- Grid classes
grid-item--column-N,grid-item--even,grid-item--second-tabletandgrid-item--third-tabletare derived fromitem_index,columnsandcolumns_mobilewith modulo arithmetic. - The saving badge is
compare_at_price | minus: price | times: 100 | divided_by: compare_at_price | round, with an "up to" string when the percentage differs across variants. - Swatch resolution walks
product.variantsfor the first variant matching each option value and captures its featured media atimage_url: width: 600, stripping the&width=600query so the element can request its own size. - Color-option detection normalizes the
general.swatches.colortranslation string and matches it againstoption.name | handle, so the swatch axis is translatable rather than pinned to "Color". - Swatches past the fourth get
is-hiddenwhen more than five exist, so a shade-heavy product doesn't push its price below the fold of the card. api-product-grid-item.liquidis an eight-line section whose only job is to expose the snippet to?section_id=; the||itemAosDelay||and||itemAosAnchor||tokens are substituted by the vendor theme's JavaScript.product-grid-item--onboarding.liquidandproduct-card-placeholder.liquidare the theme-editor placeholder variants, rendered when a section has no products configured.
Edge Cases
- Sold-out cards keep the quick-add button when
quick_buy == 'add_to_cart'— so the button can read "Sold Out" — and get asoldoutclass otherwise. - Products with a single media item, or with hover disabled, skip the secondary-image markup entirely rather than rendering an empty hover layer.
- Secondary links on cards past the first carry
tabindex="-1", so a carousel of cards doesn't add two tab stops per product. - A product with no
featured_mediafalls back to a text-onlycard--softtreatment rather than a broken image box. - Native option swatches (
option.swatch.image,option.swatch.color) are read alongside the legacy variant-image approach, so a catalog migrating to Shopify's swatches renders correctly mid-migration. - Any of the four metafields empty: the element it would render is omitted, and the card falls back to the vendor's default composition.
Platform Primitives Used
- Metafields —
custom.short_product_title,custom.product_mini_description,theme.cutlineandtheme.badgeare the merchandiser's per-product hooks into the card. - Custom elements —
product-grid-item-swatchreadsdata-swatch-imageanddata-swatch-variantfrom its chips and swaps the card image on hover. - Section Rendering API —
api-product-grid-item.liquidexposes the card to callers that fetch by?section_id=. - Liquid section schema and settings —
columns,columns_mobileandquick_buycome from the calling section;products_per_row_on_desktopis a theme setting the card falls back to when a caller passes no column count.
Where It Runs
On the collection listing through every collection.* template, on search, and on the home page and landing pages wherever a carousel, tab section or look section renders products. One snippet, every caller — and the eager-loading scope applies on the collection templates only.
What This Demonstrates
- Variant swatches and shade pickers — the primary capability: swatches on the card, resolved in Liquid from option values and variant media, with Shopify's native swatches supported alongside.
- Media and asset loading strategy — the eager/lazy split written into the card itself, scoped by template name and by a count derived from a theme setting.
How We Know
Six theme files read from the live theme — the card snippet, which alone runs to roughly 900 lines, its API section, and the onboarding and placeholder variants. Documented from the code; no client-side record of the decision survives.
That's also why the attribution below is worded the way it is. On a licensed theme the line between vendor code and ours isn't perfectly separable from the code alone. The metafield merchandising, the badge chain, the mini-description and the loading hints read as our extensions; the base grid item, its markup conventions and its API exposure pattern are the vendor's, and we don't claim them.
Related Builds
- Product card with award badges, per-variant content payload and per-card structured data — Nudestix, on Dawn: a card extended much further, with per-variant content serialized into the DOM. The comparison shows how much the theme base decides.
- Featured-collection product card with metafield hero/hover media and a tag-driven badge stack — LUS Brands' card, where the badge vocabulary comes from tags rather than a metafield and precedence is resolved the same way, in Liquid.
- Size-pill variant picker and quick add-to-cart inside product grids — the same storefront, the same card: what happens when a shopper clicks the size pill this snippet renders.
- Tabbed collection product slider and the shared merchandising card — one of this card's callers, and the reason its column classes have to be computed.
The Buy-vs-Build Question
A swatch app buys chips assembled from rules held in the app's own database rather than on the product. Building them into the card bought swatches the server writes from option values the merchandiser already maintains, and cost a snippet that's now ours to carry through every vendor theme update. The line is argued at swatches and visual options: buy or build?.
Provenance & Evidence
- Client: Three Ships — threeshipsbeauty.ca
- Surfaces: Collection listing, search, home page, landing pages
- Templates served: every
collection.*template, thesearchtemplate, andpage.*templates that mount a product section - Complexity: High
- Scale: roughly 900 lines in the card snippet
- Attribution: Deploi-modified vendor. Built on top of Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the brand licenses. The grid item is the vendor's; the metafield merchandising, badge precedence chain, mini-description and first-row loading hints are our modification of it. The split is documented from the code and should be read as our best reading of it.
- Status: Live, verified 2026-09-07
- Evidence: six theme files read from the theme
- Confidence: Moderate — documented from the code; no client-side record of the decision survives
- Primary capability: Variant swatches and shade pickers
Ready for a Grid Card Your Merchandisers Can Actually Edit?
If changing a badge on your listing page means opening a ticket, the badge is in the wrong place. Contact us today and we'll look at what your card could read from the product record instead — on the theme you already license.