Inline Size Picker on Cart and PDP Upsell Cards
Size pills added inside the premium theme's own upsell card, so a multi-variant upsell in the cart drawer, on the cart page or in a product-page "pair with" module can be sized and added where it stands instead of sending the shopper somewhere else.
A premium theme's upsell card can add a single-variant product in place, but anything with sizes bounces the shopper to a quick-view or the product page. We added a row of size pills inside the vendor's own upsell snippet: real buttons carrying the variant id and price, sold-out sizes disabled at markup level, a submit that reads "Select option" until a size is chosen, and the vendor's product form left to do the add.
The Problem
A Shopify product form submits one variant id. That is the whole contract of /cart/add: the form carries a hidden id input, and whatever value is in it is what gets added. A card that renders a product with several sizes therefore has to pick one before the button can mean anything, and a compact upsell card has nowhere to put that choice.
The theme handled it the way most do. A single-variant product got a direct add; anything else fell back to a quick-view modal or a link to the product page. On the surfaces where upsells live — the cart drawer, the cart page, the product page's pairing module — that fallback undoes the point of the card. The shopper who was one tap from adding a second item is now in a modal, or on another page, with their cart drawer closed behind them.
The Constraint
The upsell card is the vendor's. Three Ships runs Palo Alto 5.8.0 by Presidio Creative, a paid premium theme, and upsell-product.liquid is that theme's snippet: it renders a product form with a hidden variant input, its own add-to-cart button, its own error handling and its own hook into the cart-drawer refresh. Replacing it would have meant re-implementing all of that on three surfaces at once.
The same snippet is reused everywhere an upsell appears — the cart drawer, the cart page and the product page's pair-with modules — so it has no section context of its own and cannot carry a script of its own. Whatever the picker did, it had to do as markup: attributes a shared handler could read, and nothing that assumed it was the only picker on the page.
And it had to know about selling plans. A product sold on a subscription cannot be silently added as a one-time purchase from a card that never showed the plan.
What We Built
Inside the vendor's upsell-product.liquid, a .pgi-size-pills row renders when the upsell product has more than one variant. Each pill is a <button> carrying data-variant-id, data-product-id and data-price. Variants that are unavailable render as .pgi-size-pill--unavailable and are disabled in the markup itself — not styled to look inert while remaining clickable.
The form gets two marker attributes: data-pgi-product-id on the form and data-pgi-variant-input on the hidden variant id input the vendor already renders. Those two attributes are the entire interface between the card and the behavior that drives it. The submit button carries two label spans, pgi-select-size-label and pgi-add-to-cart-label; it starts as "Select option" and shows "Add to cart" once a pill is chosen, by toggling which span is visible rather than rewriting text.
Choosing a pill selects it, dims the others, swaps the displayed price from the pill's own data-price, writes its data-variant-id into the hidden input, and enables the submit. The vendor's form then does what it always did: posts to the cart, handles the error, refreshes the drawer. The behavior is a delegated click handler shared with the identical pill markup on the storefront's product-grid cards, and that handler is documented on its own page. This page is about the card side of the contract.
upsell-products.liquid, the cart-pane wrapper, keeps the vendor's routing intact: a single-variant product with no selling plans gets a direct add, and everything else falls back to the theme's quick-view. The pills only change what happens inside the card that renders.
Why This Way
Reusing the vendor's product form and only swapping the value of its hidden variant input is the smallest change that solves the problem. The theme's add-to-cart request, its error rendering and its cart-drawer refresh keep working without being touched, and a theme update that changes any of them changes them for the pills too.
Making the contract attribute-based is what lets one handler serve every card. The handler finds a form by data-pgi-product-id, a pill by its data attributes and the variant field by data-pgi-variant-input; it never matches by class hierarchy or DOM position, so the upsell card in a cart drawer and a grid card on a collection page are the same thing to it.
What we accepted in return is a picker that is markup only. It has no state of its own beyond what the pills and the hidden input hold, and it depends on a handler that lives elsewhere in the theme to do anything at all. If that handler is absent, the pills render and do nothing — the row is hidden by default until the handler reveals it, so a card without the behavior looks like the vendor's original rather than like a broken picker. That is a deliberate fallback, and it is also a real dependency on code the snippet can't see.
Why Not an App
In-cart upsell apps are the usual purchase for this, and a store that wants recommendation logic — what to offer, to whom, when — is buying something real. Their cards are the app's, rendered by the app's script after the drawer opens, with the app's own variant handling.
Here the recommendation logic wasn't the gap. The theme already chose what to upsell; the card just couldn't add a sized product. Fixing that inside the vendor's snippet meant the upsell stayed in the theme's own product form, with the theme's own error handling and drawer refresh, rather than a second cart pipeline running beside the first.
Implementation Notes
- The pill row starts
style="display:none"and is revealed by the handler, so a card rendered without the behavior available looks exactly like the vendor's default. - Two label spans,
pgi-select-size-labelandpgi-add-to-cart-label, are toggled on the submit button instead of rewriting its text, which keeps the button's accessible name in the markup rather than in a script string. - Sold-out variants render as
.pgi-size-pill--unavailableand carry thedisabledattribute, so they are unreachable by keyboard and announced as unavailable, not merely greyed. upsell-products.liquidkeeps the vendor's routing: single-variant, no selling plans, direct add; anything else, quick-view.- The upsell subtitle switches between the theme's upsell and complementary translation keys depending on which module rendered the card.
- Pill styling is shared with the grid cards' pills rather than carried in the snippet, so the two surfaces can't drift apart visually.
Edge Cases
- A product with selling plans deliberately falls back to quick-view instead of a silent one-time add, so a subscription product is never carted without the plan being visible.
- An out-of-stock upsell hides the button entirely and shows the out-of-stock message, rather than rendering a picker with every pill disabled.
- Errors render into a per-card
data-cart-errors-containerwithrole="alert", so an add that fails is announced against the card that failed and not somewhere else on the page. - In the theme editor, with no product set, the card still renders the vendor's onboarding state under
request.design_mode, so a merchandiser sees a placeholder instead of an empty form. - A single-variant upsell renders no pill row at all and adds directly, exactly as the vendor's card did before.
Platform Primitives Used
cart-ajax-api— the vendor's form posts to the cart endpoint; the pills only decide which variant id it posts.selling-plans— the presence of a selling plan is what routes a product away from the pills and into quick-view.liquid-schema-settings— which products appear as upsells, and the module subtitle, are section settings the vendor's snippets already read.
Integrations in Play
- Shopify Subscriptions — the selling-plan source on this storefront. The picker doesn't render plans; it detects them and steps aside.
Where It Runs
The cart — both the drawer and the cart page — and the product detail page, wherever the theme's pair-with module renders an upsell card. There is no template list because the trigger is the snippet, not a template: any surface that mounts upsell-product.liquid with a multi-variant product gets the pills.
What This Demonstrates
- Accessibility remediation at component level — the primary capability: a picker made of real buttons, unavailable options disabled in the markup, a submit whose name reflects whether it can be pressed, and errors announced against the card they belong to.
- Variant swatches and shade pickers — the same pill vocabulary as the storefront's grid cards, in a card the theme never expected to carry one.
- In-cart upsell and add-ons — the conversion job: a sized product added from the cart without leaving it.
How We Know
Two vendor snippets from the theme, roughly 235 lines between them, read for the pgi- markup we added and the routing the vendor kept. Documented from the code; no client-side record of the decision survives. There is no brief here that says why an inline picker was chosen over the quick-view the theme already offered — what the code shows is a set of pills, two marker attributes and a fallback that only makes sense if the vendor's form was meant to stay in charge. So this page describes the contract and stops short of intent it cannot evidence.
Related Builds
- Size-pill variant picker and quick add-to-cart inside product grids — the other half of this contract on the same storefront: the handler that reads these attributes, and the grid cards that carry the same pills.
- Configurable PDP block system on the Palo Alto theme — the same storefront and the same discipline of extending the premium theme's markup rather than replacing it.
- Pill variant picker with merchandiser-named size labels — a different storefront's size pills, on the product form itself rather than inside an upsell card.
- PDP content accordion that borrows the premium theme's collapsible contract — the same theme, the same approach: supply markup, let the vendor's runtime drive it.
The Buy-vs-Build Question
An upsell app buys the recommendation logic and brings its own card, its own script and its own cart pipeline. Extending the vendor's card bought a sized add that stays inside the theme's product form and cost a picker that can't work without a handler defined elsewhere. Which side of that you land on depends on whether the gap is what to offer or how to add it — the question on the accessibility remediation decision page.
Provenance & Evidence
- Client: Three Ships — threeshipsbeauty.ca
- Surfaces: Cart and product detail page
- Templates served: none listed; the picker follows the upsell snippet wherever a module mounts it
- Complexity: Medium
- Attribution: Built on top of Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the client licenses. Both snippets are the vendor's; the
pgi-pill markup, the marker attributes and the label spans are our modification inside them. The product form, the add request and the drawer refresh are the theme's. - Status: Live, verified 2026-09-07
- Evidence: Two vendor snippets from the theme, read for the markup added to them
- Confidence: Moderate — documented from the code; no client-side record of the decision survives
- Primary capability: Accessibility remediation at component level
Ready to Let Shoppers Add a Sized Product Without Leaving the Cart?
You dream it. We build it. If your upsell cards send sized products to a modal, Contact us today and we'll put the size choice on the card, inside the form your theme already trusts.