A collection template turned into a palette builder that carts one priced SKU
A pick-your-own palette promotion runs on a normal collection page wrapped in a <bundle-element> custom element, which holds a fixed tray of empty slots and posts one priced bundle product with the shopper's picks attached as line-item properties.
Shopify has no server-side notion of a shopper-composed set, so a pick-your-own palette promotion has to resolve into something the cart already understands. We turned the collection template into the builder: picks land in a fixed tray, and one priced bundle SKU goes to cart carrying every pick as a line-item property plus an id that makes the set editable afterwards.
Fact Strip
- Client: Nudestix
- Surfaces: Collection listing and cart
- Templates served: 5
- Complexity: High
- Attribution: Deploi-authored. These are our sections and our JavaScript, mounted in a theme based on Dawn 6.0.2.
- Status: Live, verified 2026-09-06
- Evidence: Eight theme files plus two client task-register records — confidence: strong
- Scale: Roughly 1,500 lines across sections, asset and modal snippets
The Problem
Nudestix runs "pick your own palette" promotions: choose three or five minis for one fixed price, often with a free gift attached. The merchant needs one predictable price and one line at checkout, because that is what makes the promotion hold through the funnel and what makes the order legible to fulfilment. The shopper needs to browse a normal collection and assemble a set, because the picks are the point of the promotion.
Off-the-shelf bundling resolves that tension in one of two directions: N separate line items at N separate prices, or a fixed pre-built SKU with no choice. Neither one is the promotion you sold.
The Constraint
Shopify Functions and the bundle APIs were not in play for this theme, so the promotional price had to come from a real product that carries it, and the shopper's picks had to travel as cart line-item properties. That is the whole design envelope.
The larger constraint sits underneath it: Shopify has no server-side notion of "this line item is a set." There is no object to ask what a set contains, no place to persist a half-built one, and no native route back from a cart line to the selection that produced it. Set identity, editability and in-progress state all had to be maintained client-side.
Configuration carried its own constraint. Promotions turn over faster than deploys, so a new promo had to be a metafield change a merchandiser makes, not a release.
What We Built
Two collection sections carry the builder. main-collection-product-grid-bundle.liquid renders a product-level grid; main-collection-product-grid-bundle-variants.liquid renders a shade-level grid built from collection.metafields.custom.list_variants, because some promotions are picked by shade. Both wrap the grid in a <bundle-element> custom element and render a fixed palette tray above it: bundle_subproduct_limit empty slots, plus a slot for the gift.
Configuration comes entirely from collection metafields. custom.bundle_subproduct_limit sets how many picks the tray accepts, custom.bundle_product names the priced bundle SKU actually added to cart, custom.bundle_gift_product names the gift appended automatically, and custom.bundle_text / custom.bundle_subtext carry the tray copy. Each item's descriptor line comes from my_fields.subtext_lines.
bundle.js defines the element. Each card's add button pushes an object into a subProArray — varname, varsku, varid, varcolor or varimg, varTitle, vardesc, varPrice and quantity — and the tray re-renders its slot thumbnails from that array, using the media image for product picks and a flat color swatch for shade picks. The add-to-cart button posts {id: <bundle product variant>, quantity: 1, properties: {...}} to /cart/add.json.
The properties object is where the set lives. It carries a human-readable 1 x <name> [<sku>] line per pick, so fulfilment and the customer read the same contents on the order, plus _variant_ids as a comma-joined list, _variants as JSON of id and price for downstream systems — display values carried from the client, not an authoritative price record — and a random _bundleId.
That _bundleId is the hinge. The full pick list is mirrored to localStorage under the same id, so ?edit=<bundleId> on the collection URL rehydrates the tray with the original selection. On save, the element walks /cart.json, finds the line whose properties._bundleId matches, and PATCHes it through /cart/change.js. Four modal snippets cover the states around that: warning-palette and warning-palette2 for incomplete and over-limit sets, warning-exit-palette for exit intent, success-palette for the confirmation.
Why This Way
Using one priced bundle product as the cart line is what makes the promotional price hold through checkout without a discount app and without a Function. The price is not computed, negotiated or re-applied anywhere; it is the product's price. The picks ride along as properties, which is the one channel Shopify gives you for attaching shopper-supplied detail to a line without changing what that line costs.
_bundleId in both localStorage and the line-item property is what makes an in-cart bundle editable at all. Without a shared key there is no way to map a cart line back to a shopper's selection, and "edit your palette" becomes "start over."
What that costs is the thing named in the constraint, and we took it deliberately. The set's identity is a client-side construct: a shopper who clears storage, or returns on another device, keeps the correct line at the correct price but loses the ability to edit it. We chose a durable cart line over a durable editing session.
Why Not an App
Bundle apps are a mature category and the default purchase here. The established ones all ship a builder. Our reason for not using one is specific.
A bundle app owns the bundle. It holds the set definition in its own datastore and expresses it in the cart through its own mechanism — separate lines re-priced by a cart transformer, a discount applied at checkout, or a synthetic parent line the theme cannot address. All three are fine until you need what this promotion needed: one real SKU carrying the real price, and a set the shopper can reopen and change from the cart. An app's set state isn't addressable from theme code, so the edit path has to be the app's, in the app's UI.
The second reason is surface. The builder here is the collection page, and its configuration stays inside Shopify as collection metafields, so the promotion does not depend on a subscription staying active.
Implementation Notes
<bundle-element>is a real custom element registered throughcustomElements.define, instantiated by wrapping the grid in the tag. There is no init call to sequence and nothing to re-run if the markup renders late.- The cart write is
/cart/add.jsonwith a JSON body rather than the form-encoded/cart/add, and edits go through/cart/change.jskeyed on the cart linekeyrather than on the variant id. - Duplicate picks collapse into one property line with an incremented
quantitybefore the properties object is assembled, so picking a shade twice reads as one line, not two identical ones. - The gift is appended to the properties list but is never selectable. Its variant id, sku and price come off
data-gift-*attributes rendered fromall_products[collection.metafields.custom.bundle_gift_product]. - A
beforeunloadhandler warns against leaving mid-build, and is cleared withwindow.onbeforeunload = nullimmediately before the cart write, so the successful path never triggers it. It is armed for the whole visit to a bundle collection, though, not only for a part-built tray — so ordinary browsing away from the page gets the prompt too. - Shade picks are distinguished from product picks by a
data-variant-bundleattribute on the card button; product picks read the checkedinput[name="Color"]swatch for their color and shade name. - After an edit-mode save,
history.pushStaterewrites the URL back to the plain collection path, so a refresh does not re-enter edit mode on a bundle already saved.
Edge Cases
- Fewer picks than the limit: the add-to-cart reveals an inline "N products required for checkout" message, and only escalates to the blocking modal on a second attempt.
- More picks than the limit: the over-limit modal opens and the extra pick is discarded rather than silently swapping out an existing one.
- A sold-out bundle SKU renders a sold-out label in place of the price and the add control, on both the desktop and the mobile tray.
- Removing a pick re-indexes every tray slot rather than mutating one in place, so slot order cannot drift from the array behind it.
- Shade picks with no image fall back to a flat background color swatch; product picks with no swatch fall back to the featured image.
- Edit mode is entered only when
?edit=<id>is present and a matchinglocalStorageentry exists, so a shared or stale link lands on the normal collection page.
Platform Primitives Used
- Cart AJAX API —
/cart/add.jsonto write,/cart.jsonto locate the line by property,/cart/change.jsto rewrite it. - Metafields — collection-scoped configuration: pick limit, priced bundle SKU, gift and tray copy.
- Liquid section schema and settings — the two collection sections and their theme-editor settings.
- Custom elements —
<bundle-element>owns the tray, the pick array and the cart write.
Where It Runs
The builder is mounted by five collection templates: a campaign collection, a general bundle collection, and three shade-level collections built for specific product families. Product-level and shade-level promotions share the element and the cart contract; only the grid section and the metafield values differ.
What This Demonstrates
Fixed bundles and kit merchandising — the shopper-composed end of that capability: a set assembled on the storefront that still reaches checkout as one priced product with readable contents.
How We Know
Eight theme files were read directly: two collection sections, the JavaScript asset that defines the element, a card snippet and four modal snippets. Alongside them sit two client task-register records referencing this promotion's bundle work. Confidence is strong — the metafield keys, cart routes and property names above are the ones the storefront uses. We make no claim about how the promotion performed.
Related Builds
- The "Save With Sets" PDP bundle-kit module — same storefront, a kit configured on another product's page and carted as one ordinary product.
- The bundle product template — same storefront, an add-to-bag that can submit several lines at once.
The Buy-vs-Build Question
Bundles are one of the most crowded app categories on Shopify, and buying is usually the right call. What tips a build is a requirement the category cannot express — here, one real SKU carrying the price and a set the shopper can reopen. Our full position, including where we would buy, is on the fixed bundles and multipacks decision page.
Ready to Build the Bundle Mechanic Your Promotion Actually Needs?
Most bundle work should be an app install. When it shouldn't, one requirement is why. Tell us yours and we'll tell you which side of the line it falls on. Contact us today.