Shopify Builds>Nudestix>Side-by-side product comparison drawer and modal table

Side-by-Side Product Comparison Drawer and Modal Table

A compare-products flow with no app behind it: card checkboxes write the selection to localStorage, a drawer pinned to the bottom of the viewport carries it from collection to product page and back, and the modal table fetches each product's long-form copy from that product's own page section at the moment the shopper asks to compare.

Shopify has no compare-products primitive, and a collection tile cannot tell a shopper how two foundation sticks differ. This flow holds the selection in localStorage so it survives the trip to a product page, pins a drawer to the viewport, and fills the comparison table by asking each product's own page section for its copy through the Section Rendering API — once per product, only when the table opens.

The Problem

Shopify ships nothing for comparison. A collection grid renders a tile per product — image, title, price — and the platform has no notion of "these three, side by side." For a catalog of distinct products that's fine. For color cosmetics it isn't. Nudestix sells families of near-identical sticks and balms: foundation sticks that differ by finish and coverage, blush balms that differ by skin type and formulation, bronzers that differ by shade count. From a grid tile they look like the same product in a different photo.

The merchandising need was specific: let a shopper pick two to five products from a collection page and see finish, skin type, formulation, shade count, price and the product's "what it is" and "what it does" copy in one table, before choosing. And let the merchandiser decide, per collection, how many slots that table has.

The Constraint

The selection has to survive navigation. A shopper ticks two products on a collection page, opens a third to read about it, comes back and ticks it too — so the state can't live in a section, which is re-rendered from scratch on every page.

The comparison copy is the harder part. "What it is" and "what it does" live in a metaobject-backed product metafield, custom.product, which is not exposed on the collection card. Reading it at render time for every product on the grid would mean a metaobject lookup inside the card loop — one per tile — on a page that already renders forty tiles.

The theme is based on Dawn 6.0.2, which predates theme blocks and offers no app-block escape hatch for a feature like this. Whatever we built had to be plain Liquid and JavaScript inside a theme that doesn't have a slot for it. And the compare limit had to be merchandiser-controlled per collection, without a deploy.

What We Built

product-compare.js defines three custom elements. <product-compare-checkbox> sits on every product card. <product-compare-drawer> is pinned to the bottom of the viewport and shows the current selection wherever the shopper is. <product-compare-table> renders the side-by-side table inside a modal dialog.

Selection state. The selection is a Map, serialized to localStorage under the key compareProducts through a writer debounced to 200ms. Because it's in localStorage rather than in the page, it persists across collection-to-product-to-collection navigation; on every page load restoreCheckedInputs() reads it back and re-checks the matching cards so the grid and the drawer agree. The drawer and the modal table share that one selection model: removing a product from either updates the other and unchecks the card it came from.

Card-level data. compare-checkbox.liquid stamps each product's grid-visible attributes onto its checkbox as data-comp-* attributes — handle, product type, image, finish, skin type, benefits, formulation, application, shade range, availability. Those are the values the drawer and the short rows of the table need, and they're already on the card, so nothing is fetched to show them.

Lazy copy. The long-form rows are fetched only when the shopper presses Compare Products. fetchProductDesc() requests /products/<handle>?sections=main-product — the Section Rendering API returning the product page's main section as HTML — parses the response with DOMParser, and reads two JSON script tags, data-what-it-is and data-what-it-does, that product-compare-data.liquid emits into that section from product.metafields.custom.product.value. Results are memoized in an in-memory sectionCache Map keyed by product id, so a product compared twice in a session is fetched once. All fetches run through Promise.allSettled, so one failure doesn't block the table.

Merchandiser control. The compare limit resolves from collection.metafields.custom.comparison.value.limit, falling back to shop.metaobjects.comparison.default.limit. A limit of zero switches the feature off for that collection, and the section then doesn't enqueue the comparison CSS or JavaScript at all.

ANY PAGE · SELECTIONON "COMPARE PRODUCTS" · COPY Product card checkbox data-comp-* attributes Pinned drawer same selection model Modal table short rows from the cards /products/<handle>?sections=main-product Section Rendering API, read-only DOMParser data-what-it-is sectionCache keyed by product id Long-form rows what it is · what it does localStorage compareProducts survives navigation writes, debounced 200ms restores on every page load on open: fetch once per product parse HTML memoize one fetch per product per session
Two flows, two stores. The selection lives in localStorage so it outlives any one page; the long-form copy stays on the product page's own section and is fetched through the Section Rendering API only when the table opens — then memoized, so a product is asked for once per session.

Why This Way

The Section Rendering API is normally used to re-render a section you're looking at. Here it's used as a read-only data endpoint. The product page already emits the two JSON islands for its own purposes, so the comparison table asks that page for them — once per product, once per session, and only when a shopper opens the table. The alternative was to duplicate the metaobject copy into every collection card, which would put a metaobject lookup inside a forty-tile render loop to serve a table most shoppers never open.

localStorage rather than cart attributes or a customer metafield keeps the feature working for logged-out traffic, and it keeps a browsing action from mutating the cart. Custom elements with connectedCallback mean the checkbox markup can be dropped into any card snippet and it wires itself; there's no registration step for a new card type.

There's a cost, and it's a coupling. The product page's main section is now part of the comparison tool's contract: the JSON islands it emits are the table's data source, so a change to that section has to keep them intact or the table goes blank. The first open of the table also pays one round-trip per selected product — hence the cap on the selection and the cache on the results. And the state is per browser, not per customer — a shopper who switches devices starts over.

Why Not an App

Comparison apps for Shopify inject their own script tag on every collection page, keep their own mirror of the product catalog, and re-render the grid client-side to add their controls. That's a second source of truth for product attributes and a render-blocking script on the storefront's busiest listing pages.

Here the attributes already exist in Shopify — metafields on the product, a metaobject for the limit — and the theme reads them directly. There's no catalog sync to fall out of date, no extra script on the collection page, and the merchandiser sets the number of comparison slots by editing a metaobject entry rather than an app dashboard. For a store whose comparison data lives in an app already, the calculus is different; here it didn't.

Implementation Notes

  • product-compare.js registers product-compare-checkbox, product-compare-drawer and product-compare-table, each guarded with customElements.get() so the file is safe to enqueue twice.
  • Comparison copy is pulled with fetch('/products/<handle>?sections=main-product') and read out of <script type="application/json"> tags marked data-what-it-is and data-what-it-does, emitted by product-compare-data.liquid inside the product page's main section.
  • The selection is persisted as the JSON entries of a Map in localStorage['compareProducts'], written through a debounce(200) wrapper so rapid ticking doesn't thrash storage.
  • A category guard, isDifferentCategory(), compares data-comp-product-type against the first selected product and refuses a mixed-category comparison with an inline warning, so a shopper can't line a bronzer up against a lip balm.
  • The table's rows are generated from a Liquid string list in product-compare-modal.liquid — formulation, application, finish, skin type, what it is, what it does, shade range — so adding a row is a one-line change. The application row is relabelled "APPLY ON" in the header.
  • Opening the modal calls navbarToggle(true), which forces the sticky header into its hidden state on #shopify-section-header so the fixed header can't overlap the table, and reverses it on close.
  • Limit resolution order: the collection's custom.comparison metafield, then the comparison.default metaobject, then zero — and zero means the assets aren't enqueued.

Edge Cases

  • Hitting the limit, or picking a product from a different category, reverts the checkbox to unchecked and shows a message rather than failing silently.
  • restoreCheckedInputs() runs on every page load, so a shopper returning from a product page sees the same boxes ticked that they left.
  • Empty slots in the table render an "Add a product" placeholder that closes the modal, returning the shopper to the grid to pick another item.
  • Clearing the last product auto-closes the modal and hides the drawer, so there's never an empty comparison on screen.
  • A fetch failure for one product is caught per product and returns null copy for that column rather than rejecting the whole table.

Platform Primitives Used

  • section-rendering-api — used as a read-only data endpoint: the product page's main section is fetched for its JSON islands, never rendered into the page.
  • metafieldscustom.product holds the metaobject-backed comparison copy; custom.comparison on a collection overrides the compare limit.
  • metaobjects — the comparison metaobject carries the store-wide default limit that a collection can override.
  • liquid-schema-settings — the collection grid section resolves the limit and decides whether to enqueue the feature at all.
  • custom-elements — checkbox, drawer and table are three self-wiring elements that share one selection model.
  • local-storage — the selection's home between page loads, written debounced and restored on every load.

Where It Runs

On the collection listing, where the checkboxes and the compare button live; on the product detail page, whose main section emits the JSON islands the table reads; and globally, because the drawer follows the shopper. Four templates mount it: the standard collection template, a collection.video-banner variant, the standard product template and product.quickview.

What This Demonstrates

  • Design system and reusable component library — the primary capability. Three custom elements with one shared state model, dropped into an existing card snippet without a registration step.
  • Product comparison and spec tables — the flow itself: a selection held in localStorage and a table whose rows are configured per collection by metaobject.

In the taxonomy this build is also indexed under metafield-driven PDP content, since the copy it compares is the same metaobject copy the product page renders.

How We Know

Eight theme files — one JavaScript asset, five snippets and two sections — roughly 1,000 lines between them, plus one client task-register record. The record documents instrumentation work on the comparison feature in early 2025, which tells us the feature was already live by then; it does not describe the feature itself. The selection model, the lazy fetch and the limit resolution on this page are read from the code rather than from any document.

Related Builds

The Buy-vs-Build Question

A comparison app buys the drawer and the table for a monthly fee, plus a catalog sync and a script on every collection page. Building it bought a flow that reads product data where it already lives and a limit the merchandiser sets in a metaobject — and cost a coupling to the product page's main section that has to be kept intact. Whether your comparison data already lives in Shopify decides most of it: Product comparison and spec tables: buy or build?

Provenance & Evidence

  • Client: Nudestix — nudestix.com
  • Surfaces: Collection listing, product detail page, global
  • Templates served: four — collection, collection.video-banner, product, product.quickview
  • Complexity: High
  • Attribution: Deploi-authored. The three custom elements, the snippets and the metaobject-driven limit are ours. They run in a theme based on Dawn 6.0.2.
  • Status: Live, verified 2026-09-06
  • Evidence: Eight theme files — one JavaScript asset, five snippets and two sections — plus one client task-register record
  • Confidence: Strong — the code is read directly from the theme, and the client record establishes the feature was live; the mechanism is documented from the code
  • Primary capability: Design system and reusable component library

Ready to Let Shoppers Compare Without Leaving Your Store?

You dream it. We build it. If your shoppers are opening three tabs to work out which of your products is theirs, Contact us today and we'll show you what a native compare flow looks like on your catalog.

More builds