Shopify Builds>Three Ships>Collection grid card on a licensed theme: metafield merchandising, a badge precedence chain, a hover-swap swatch element and first-row loading hints

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-tablet and grid-item--third-tablet are derived from item_index, columns and columns_mobile with 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.variants for the first variant matching each option value and captures its featured media at image_url: width: 600, stripping the &width=600 query so the element can request its own size.
  • Color-option detection normalizes the general.swatches.color translation string and matches it against option.name | handle, so the swatch axis is translatable rather than pinned to "Color".
  • Swatches past the fourth get is-hidden when more than five exist, so a shade-heavy product doesn't push its price below the fold of the card.
  • api-product-grid-item.liquid is 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.liquid and product-card-placeholder.liquid are 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 a soldout class 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_media falls back to a text-only card--soft treatment 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

  • Metafieldscustom.short_product_title, custom.product_mini_description, theme.cutline and theme.badge are the merchandiser's per-product hooks into the card.
  • Custom elementsproduct-grid-item-swatch reads data-swatch-image and data-swatch-variant from its chips and swaps the card image on hover.
  • Section Rendering APIapi-product-grid-item.liquid exposes the card to callers that fetch by ?section_id=.
  • Liquid section schema and settingscolumns, columns_mobile and quick_buy come from the calling section; products_per_row_on_desktop is 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

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, the search template, and page.* 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.

More builds

Product card with award badges, per-variant content payload and per-card structured data

One snippet renders the whole Nudestix grid card: tag and award badges, quick view, wishlist, comparison, an in-grid add to bag, and a per-variant data payload the shade switcher reads without a fetch.

Nudestix

Featured-collection product card with metafield hero/hover media and a tag-driven badge stack

The card that renders inside the homepage's main merchandising rail: hero and hover art chosen in the Shopify admin, a responsive image ladder built by hand, and a badge stack resolved from tags, price and block settings at render time.

LUS Brands

Size-pill variant picker and quick add-to-cart inside product grids

One click handler, registered once on `document` in the capture phase, turns any grid card with a size-pill row into a variant selector and an add to cart — including cards that did not exist when the page loaded.

Three Ships

Tabbed collection product slider and the shared merchandising card

One section renders every configured collection at once and switches between them with a class, so moving from Serums to Cleansers waits on nothing — and one card snippet backs the shelf, the blog and the rewards page.

Three Ships

Font delivery and a template-conditioned image preload in a licensed theme's layout

Three Ships' `theme.liquid` decides its own font delivery on a licensed theme — the picked faces preloaded, an Adobe Fonts `@font-face` block inlined with its loader demoted behind it — and emits one image preload on exactly one template type, under an explicit condition.

Three Ships