Shopify Builds>Three Ships>Size-pill variant picker and quick add-to-cart inside product grids

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.

Grid cards for multi-size products either add an arbitrary variant or push the shopper to the product page. We added a capture-phase click handler in the layout of Palo Alto 5.8.0, a paid premium theme, that turns size pills into a variant selector, blocks the add until a size is chosen, and posts the chosen variant to the cart AJAX API.

Fact Strip

  • Client: Three Ships — clean skincare, threeshipsbeauty.ca
  • Surfaces: Collection listing, product detail page, cart and home
  • Scale: roughly 90 lines of JavaScript plus its styling, registered once
  • Complexity: Medium
  • Attribution: The handler and its styling are ours. They live inside Palo Alto 5.8.0 by Presidio Creative — a paid premium theme the brand licenses, in that theme's layout file, and the card markup they bind to is rendered by the storefront's own grid sections. This is our behavior added to a vendor theme, not a grid we wrote.
  • Status: Live — verified 2026-09-06
  • Evidence: Moderate — documented from the code; no client-side record of the decision survives
  • Platform primitives: 3
  • Integrations in the same DOM: Rebuy

The Problem

Several Three Ships products sell in more than one size. On any grid surface — a collection page, an upsell slider, a recommendation carousel — a card carrying a single "Add to cart" button has exactly two options, and both are bad. It can add a default variant the shopper never chose, which is a support ticket and a return. Or it can send them to the product page to make a decision they were ready to make on the card, which on mobile is a navigation, a scroll and a way back.

Merchandising wanted the third thing: the sizes visible on the card, the price updating to match the size, and the add working in place. Not a quick-view modal — the card itself.

The Constraint

Where the cards come from is the whole problem. Grid cards on this storefront are rendered by many different sections, and a good number of them arrive after the page has loaded: Section Rendering API responses, upsell sliders, recommendation widgets. Any handler bound at DOMContentLoaded to the elements that exist at that moment misses every card that shows up later.

The cards are also not empty containers. They sit inside components that already handle clicks and already bind their own add-to-cart. An in-card size selector has to run before those handlers, or the theme adds a variant while the shopper is still choosing one.

Sliders add a third limit. They measure their item heights once and cache them, so revealing a hidden pill row inside a carousel changes the content height under a layout that thinks it already knows the answer, unless the slider is told to look again.

And the styling had nowhere obvious to go. This is a paid vendor theme whose live stylesheets are served from a CDN and are not editable in the theme, so pill styling could not be added to the sheet that styles the buttons it has to match.

What We Built

A single document.addEventListener('click', handler, true) in theme.liquid, registered in the capture phase, handling two cases.

Case one: a size pill was clicked. The handler reads data-product-id off the pill and finds the matching [data-pgi-product-id] form. It clears is-selected and is-dimmed from every sibling pill carrying that product id, marks the clicked pill selected, writes its data-variant-id into the form's [data-pgi-variant-input], hides the "select size" label, reveals the add-to-cart label, and rewrites .pgi-selected-price from the pill's own data-price, held in cents and formatted in place. Scoping by product id rather than by DOM proximity is deliberate: the same product can appear twice on a page — once in the grid, once in an upsell rail — and both cards move together.

Case two: add to cart was clicked. Any click on [data-add-to-cart] inside a card that has a .pgi-size-pills block is intercepted. If no pill is selected, the click is cancelled with stopImmediatePropagation() — which is what the capture phase buys, since the theme's own listener never runs — and the pill row is revealed instead. When that card sits inside a [data-upsell-slider], a theme:slider:resize CustomEvent is dispatched inside requestAnimationFrame, so the carousel re-measures after the new row has actually been laid out rather than before.

If a pill is selected, the native submit is cancelled and the selected variant is posted to /cart/add.js as FormData with a quantity of one. Afterwards the code dispatches two events: cart:refresh on document and cart:updated on window. Two names, because two different cart implementations are listening — the theme's own drawer and the Rebuy cart use different ones, and the add has to update whichever is on screen. The button then flashes "Added!" for two seconds and restores its exact original markup.

The pill styling ships as an inline <style> block in the same layout, mapping the pills onto the theme's own outline-button CSS custom properties — the --OUTLINE-BUTTONS-PRIMARY-* family — with !important and a mobile breakpoint that stacks the label and shrinks the pills. Borrowing the vendor's tokens rather than picking colors means the pills change when the merchant changes their button styling in the theme editor.

Why This Way

Capture-phase delegation on document is the only binding that satisfies both hard requirements at once. It works for cards that do not exist yet, because nothing is bound to the cards. And it runs before the handlers on the components wrapping those cards, which is the only position from which the theme's own add-to-cart can be cancelled rather than raced.

The attribute contract does the rest of the work. The handler knows a card by [data-pgi-product-id], a pill by .pgi-size-pill with its three data attributes, and the variant field by [data-pgi-variant-input]. Nothing is matched by tag, class hierarchy or position, so the same behavior applies to a collection card, a slider card and a recommendation card without knowing anything about the sections that emitted them.

Two costs came with the approach. A capture-phase listener on document sees every click on the page and has to decide quickly that almost all of them are none of its business, which puts a small permanent tax on an event that fires constantly. And both the behavior and the styling live in the layout rather than in the theme's script and style bundles, because the compiled bundles are not editable here — so this component is not where a developer would look for it first.

Why Not an App

In-grid size selection is normally bought, as part of a quick-view or "quick add" app. Those apps are competent and they are also the wrong shape for this storefront, for a reason that has nothing to do with quality.

The requirement here is interception. The add-to-cart button on the card belongs to the theme, and the desired behavior is to stop it — cancel the add, show the sizes, and let the second click through. A third-party script cannot reliably pre-empt a theme's own listener; the usual way an app gets there is to replace the card, which means the vendor now renders the grid, owns its markup, and becomes something the theme's filters, sliders and cart events have to agree with. On a paid premium theme, replacing the card is a large surface to hand over for one behavior.

The other half is the cart. This storefront has two things listening for cart changes, and getting an add to register in both is a matter of firing the right event names — which you can do in eight lines when you own the call, and cannot do at all from inside a vendor widget's success callback. The whole handler is roughly 90 lines against the native cart AJAX API, and it does not add a script tag to any page.

Implementation Notes

  • The listener is registered with useCapture = true on document, which is what lets it pre-empt the theme's own add-to-cart binding rather than compete with it.
  • The card contract is entirely attribute-based: [data-pgi-product-id] on the form, .pgi-size-pill carrying data-product-id, data-variant-id and data-price, and [data-pgi-variant-input] as the hidden variant field.
  • Price is rewritten client-side from the pill's data-price in cents, so changing size costs no server round-trip.
  • The add posts FormData with the variant id and a quantity of one to /cart/add.js, then dispatches cart:refresh on document and cart:updated on window.
  • theme:slider:resize is dispatched inside requestAnimationFrame so the carousel measures after the pill row has been laid out, not during the same frame that reveals it.
  • Pill CSS is inline in the layout, using !important against the theme's --OUTLINE-BUTTONS-PRIMARY-* custom properties, because the theme's live stylesheets are CDN-served and not editable in the theme.
  • Sibling pills are cleared by product id, so a product rendered twice on one page keeps both of its cards in the same state.

Edge Cases

  • A first click on add-to-cart with no size chosen adds nothing — it reveals the size row instead, so an arbitrary default variant can never reach the cart from a grid.
  • Cards with no .pgi-size-pills block are ignored entirely and fall through to the theme's normal add-to-cart, unchanged.
  • Missing form and slider ancestors are null-checked before use, so a card in an unexpected wrapper does not throw.
  • The "Added!" state restores the button's exact original innerHTML after two seconds, so a label containing price markup survives the round trip.
  • Both cart event names fire on every add, so whichever cart is mounted on that page hears about it.

Platform Primitives Used

  • Cart AJAX API — the add is a FormData POST to /cart/add.js, with no vendor layer between the pill and the cart.
  • Section Rendering API — cards arriving in section-render responses are the reason the binding is delegated rather than bound.
  • CSS custom properties — the pills read the vendor theme's own outline-button tokens, so they inherit merchant styling changes instead of overriding them.

Integrations in Play

  • Rebuy — coexists with this handler. Rebuy widgets inject cards after load, which the delegated binding covers, and the Rebuy cart is one of the two listeners the dual event dispatch exists to reach.

Where It Runs

Registered once in the theme layout, so it applies wherever a card carrying a size-pill row renders: collection listings, product-page recommendation rails, home-page carousels and upsell sliders in the cart. There is no template list — the trigger is the card contract, not the template.

What This Demonstrates

Primary: variant swatches and shade pickers — the same capability as a shade swatch on a product page, moved to a card and reduced to the one decision that blocks the purchase.

It also sits in collection merchandising and product rails, and it is this corpus's clearest example of quick view and in-grid add to cart built rather than rented — a category where the platform gives the mechanism away and the app market charges monthly for the binding.

How We Know

The layout file in the theme, read for the handler and its inline styling, plus the attribute contract it depends on, which is emitted by the storefront's own grid sections. Documented from the code; no client-side record of the decision survives. There is no brief here explaining why capture-phase delegation was chosen over per-card binding — what we can show is a listener registered with useCapture and a set of null checks that only make sense if cards were arriving late. So this page describes mechanisms and stops short of intent it cannot evidence.

Related Builds

The Buy-vs-Build Question

Every version of this problem starts with the same platform fact: a card renders a product, but a cart takes a variant, and something has to bridge the two before the button is useful. Where that bridge belongs — your theme or a vendor's script — is the decision we set out in variant limits and complex options: buy or build?.

Ready to Let Shoppers Choose a Size Without Leaving the Grid?

If your collection cards add the wrong variant or send people away to pick one, the gap is a binding, not a redesign. Contact us today to talk about what in-grid selection would take on the theme you already run.

More builds