Bundle offer
workbench.
Build a visual bundle offer section before editing Shopify code. Tune the copy, example products, prices, accent, and layout, then copy a generated sections/jlx-bundle-offer.liquid file. The free version adds the first available variant for each selected product; variant-aware bundles are a custom build.
Preview the bundle before touching code.
Tune the storefront-facing copy, product examples, accent, and layout. The generated Liquid below uses these values as theme-editor defaults.
Buy the routine and save
A simple 3-step bundle with one cart action and a clear savings cue.
$34$28
$52$42
$46$38
{% comment %}
Jelonyx - Bundle Offer Workbench Section
Generated from jelonyx.com/shopify/sections/bundle-offer
Free version: visual bundle + add first available variant for each selected product.
{% endcomment %}
{%- liquid
assign bundle_size = 0
assign bundle_total = 0
assign compare_total = 0
for i in (1..4)
assign key = 'product_' | append: i
assign p = section.settings[key]
if p != blank
assign bundle_size = bundle_size | plus: 1
assign bundle_total = bundle_total | plus: p.price
if p.compare_at_price > p.price
assign compare_total = compare_total | plus: p.compare_at_price
else
assign compare_total = compare_total | plus: p.price
endif
endif
endfor
-%}
{%- if bundle_size > 0 -%}
<section
id="jlx-bo-{{ section.id }}"
class="jlx-bo jlx-bo--{{ section.settings.layout }}"
style="--jlx-bo-accent: {{ section.settings.accent_color }};"
>
<div class="jlx-bo__inner">
{%- if section.settings.badge != blank -%}
<span class="jlx-bo__badge">{{ section.settings.badge }}</span>
{%- endif -%}
<div class="jlx-bo__copy">
{%- if section.settings.heading != blank -%}
<h2 class="jlx-bo__heading">{{ section.settings.heading }}</h2>
{%- endif -%}
{%- if section.settings.subheading != blank -%}
<p class="jlx-bo__subheading">{{ section.settings.subheading }}</p>
{%- endif -%}
</div>
<div class="jlx-bo__products">
{%- assign rendered_count = 0 -%}
{%- for i in (1..4) -%}
{%- assign key = 'product_' | append: i -%}
{%- assign p = section.settings[key] -%}
{%- if p != blank -%}
{%- assign rendered_count = rendered_count | plus: 1 -%}
<article class="jlx-bo__item">
{%- if p.featured_image != blank -%}
{{
p.featured_image
| image_url: width: 560
| image_tag:
width: 280,
height: 280,
class: 'jlx-bo__img',
alt: p.featured_image.alt | escape,
loading: 'lazy'
}}
{%- endif -%}
<div class="jlx-bo__item-copy">
<h3>{{ p.title }}</h3>
<p>
{%- if p.compare_at_price > p.price -%}
<s>{{ p.compare_at_price | money }}</s>
{%- endif -%}
<span>{{ p.price | money }}</span>
</p>
</div>
</article>
{%- if rendered_count < bundle_size -%}
<span class="jlx-bo__plus" aria-hidden="true">+</span>
{%- endif -%}
{%- endif -%}
{%- endfor -%}
</div>
<div class="jlx-bo__summary">
<span class="jlx-bo__summary-label">Bundle total</span>
<strong>{{ bundle_total | money }}</strong>
{%- if compare_total > bundle_total -%}
<s>{{ compare_total | money }}</s>
{%- endif -%}
{%- if section.settings.savings_label != blank -%}
<span class="jlx-bo__saving">{{ section.settings.savings_label }}</span>
{%- endif -%}
</div>
<form class="jlx-bo__form" id="jlx-bo-form-{{ section.id }}">
{%- assign all_available = true -%}
{%- for i in (1..4) -%}
{%- assign key = 'product_' | append: i -%}
{%- assign p = section.settings[key] -%}
{%- if p != blank -%}
{%- unless p.selected_or_first_available_variant.available -%}
{%- assign all_available = false -%}
{%- endunless -%}
<input type="hidden" name="items[][id]" value="{{ p.selected_or_first_available_variant.id }}">
<input type="hidden" name="items[][quantity]" value="1">
{%- endif -%}
{%- endfor -%}
<button type="submit" class="jlx-bo__btn" {% unless all_available %}disabled{% endunless %}>
{%- if all_available -%}
{{ section.settings.button_label | default: 'Add bundle to cart' }}
{%- else -%}
One or more items sold out
{%- endif -%}
</button>
</form>
</div>
</section>
<style>
#jlx-bo-{{ section.id }} {
padding: clamp(40px, 8vw, 84px) 20px;
background: var(--color-background, #ffffff);
color: var(--color-foreground, #171717);
}
#jlx-bo-{{ section.id }} .jlx-bo__inner {
max-width: 1120px;
margin: 0 auto;
display: grid;
gap: 24px;
justify-items: center;
text-align: center;
}
#jlx-bo-{{ section.id }} .jlx-bo__badge {
display: inline-flex;
padding: 6px 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--jlx-bo-accent), white 82%);
color: #171717;
font-size: 12px;
font-weight: 700;
}
#jlx-bo-{{ section.id }} .jlx-bo__copy {
max-width: 620px;
display: grid;
gap: 10px;
}
#jlx-bo-{{ section.id }} .jlx-bo__heading {
margin: 0;
font-size: clamp(28px, 4vw, 48px);
line-height: 1;
}
#jlx-bo-{{ section.id }} .jlx-bo__subheading {
margin: 0;
color: rgba(23, 23, 23, 0.68);
line-height: 1.6;
}
#jlx-bo-{{ section.id }} .jlx-bo__products {
width: 100%;
display: flex;
align-items: stretch;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
#jlx-bo-{{ section.id }} .jlx-bo__item {
flex: 1 1 180px;
max-width: 240px;
border: 1px solid rgba(23, 23, 23, 0.12);
border-radius: 8px;
padding: 12px;
display: grid;
gap: 12px;
background: #ffffff;
text-align: left;
}
#jlx-bo-{{ section.id }} .jlx-bo__img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 6px;
background: #f3f3f3;
}
#jlx-bo-{{ section.id }} .jlx-bo__item-copy {
display: grid;
gap: 4px;
}
#jlx-bo-{{ section.id }} .jlx-bo__item-copy h3 {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
#jlx-bo-{{ section.id }} .jlx-bo__item-copy p {
margin: 0;
display: flex;
gap: 8px;
color: rgba(23, 23, 23, 0.68);
font-size: 14px;
}
#jlx-bo-{{ section.id }} .jlx-bo__item-copy span {
color: #171717;
font-weight: 700;
}
#jlx-bo-{{ section.id }} .jlx-bo__plus {
align-self: center;
color: rgba(23, 23, 23, 0.42);
font-size: 24px;
}
#jlx-bo-{{ section.id }}.jlx-bo--compact .jlx-bo__products {
flex-direction: column;
max-width: 520px;
}
#jlx-bo-{{ section.id }}.jlx-bo--compact .jlx-bo__item {
max-width: none;
grid-template-columns: 84px 1fr;
align-items: center;
}
#jlx-bo-{{ section.id }} .jlx-bo__summary {
width: min(100%, 420px);
display: grid;
gap: 4px;
padding: 18px 22px;
border: 1px solid color-mix(in srgb, var(--jlx-bo-accent), black 18%);
border-radius: 8px;
background: color-mix(in srgb, var(--jlx-bo-accent), white 88%);
}
#jlx-bo-{{ section.id }} .jlx-bo__summary-label,
#jlx-bo-{{ section.id }} .jlx-bo__saving {
font-size: 13px;
color: rgba(23, 23, 23, 0.68);
}
#jlx-bo-{{ section.id }} .jlx-bo__summary strong {
font-size: 32px;
line-height: 1;
}
#jlx-bo-{{ section.id }} .jlx-bo__summary s {
color: rgba(23, 23, 23, 0.48);
}
#jlx-bo-{{ section.id }} .jlx-bo__btn {
width: min(100%, 420px);
border: 0;
border-radius: 6px;
padding: 16px 24px;
background: var(--jlx-bo-accent);
color: #171717;
font: inherit;
font-weight: 800;
cursor: pointer;
}
@media (max-width: 640px) {
#jlx-bo-{{ section.id }} .jlx-bo__products,
#jlx-bo-{{ section.id }}.jlx-bo--compact .jlx-bo__products {
flex-direction: column;
}
#jlx-bo-{{ section.id }} .jlx-bo__item {
max-width: none;
}
#jlx-bo-{{ section.id }} .jlx-bo__plus {
display: none;
}
}
</style>
<script>
(function () {
var form = document.getElementById('jlx-bo-form-{{ section.id }}');
if (!form) return;
form.addEventListener('submit', function (event) {
event.preventDefault();
var button = form.querySelector('.jlx-bo__btn');
var ids = form.querySelectorAll('input[name="items[][id]"]');
var quantities = form.querySelectorAll('input[name="items[][quantity]"]');
var items = Array.prototype.map.call(ids, function (input, index) {
return {
id: parseInt(input.value, 10),
quantity: parseInt(quantities[index].value, 10)
};
});
if (!button || !items.length) return;
var originalText = button.textContent.trim();
button.disabled = true;
button.textContent = 'Adding...';
fetch('/cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ items: items })
})
.then(function (response) { return response.json(); })
.then(function (data) {
if (data.status) throw new Error(data.description || 'Cart add failed');
button.textContent = 'Added';
document.dispatchEvent(new CustomEvent('cart:refresh'));
setTimeout(function () {
button.textContent = originalText;
button.disabled = false;
}, 2000);
})
.catch(function () {
button.textContent = 'Check variants';
button.disabled = false;
});
});
})();
</script>
{%- elsif request.design_mode -%}
<div style="padding: 60px 20px; text-align: center; border: 1px dashed rgba(0,0,0,0.22);">
Add products in the section settings to preview the bundle.
</div>
{%- endif -%}
{% schema %}
{
"name": "Bundle offer",
"tag": "section",
"class": "section",
"settings": [
{ "type": "text", "id": "heading", "label": "Heading", "default": "Buy the routine and save" },
{ "type": "textarea", "id": "subheading", "label": "Subheading", "default": "A simple 3-step bundle with one cart action and a clear savings cue." },
{ "type": "text", "id": "badge", "label": "Badge", "default": "Routine bundle" },
{ "type": "product", "id": "product_1", "label": "Product 1" },
{ "type": "product", "id": "product_2", "label": "Product 2" },
{ "type": "product", "id": "product_3", "label": "Product 3" },
{ "type": "product", "id": "product_4", "label": "Product 4" },
{ "type": "text", "id": "savings_label", "label": "Savings label", "default": "Save $24 buying together" },
{ "type": "text", "id": "button_label", "label": "Button label", "default": "Add bundle to cart" },
{ "type": "color", "id": "accent_color", "label": "Accent color", "default": "#B8D67A" },
{
"type": "select",
"id": "layout",
"label": "Layout",
"options": [
{ "value": "cards", "label": "Cards" },
{ "value": "compact", "label": "Compact list" }
],
"default": "cards"
}
],
"presets": [
{ "name": "Bundle offer" }
]
}
{% endschema %}Install path
- 01Copy the generated Liquid.
Use the workbench first. The copied code includes the section markup, scoped CSS, AJAX cart script, and Shopify schema.
- 02Create the section file.
In Shopify Admin, open Online Store > Themes > Edit code. Add a new section called
jlx-bundle-offer. - 03Paste and save.
Replace any placeholder content with the generated code and save the file.
- 04Add it in the theme editor.
Open Customize, navigate to the template where the bundle should appear, then add Bundle offer.
- 05Select products and test.
Pick 2 to 4 products, test the button on a duplicate theme, and confirm cart count or cart drawer refresh before publishing.
Theme settings
The product names and prices in the workbench are preview examples. In Shopify, live product data comes from the product pickers.
headingtextMain section headline. The workbench value becomes the default in the generated schema.subheadingtextareaShort merchant-facing explanation below the heading.badgetextOptional label above the offer, such as "Routine bundle" or "Save more".product_1 - product_4productShopify product pickers. The storefront uses live product titles, images, prices, and first available variants.savings_labeltextManual savings copy. The section does not create a discount by itself.button_labeltextCall-to-action copy for the add bundle button.accent_colorcolorAccent used for badge, summary border, and button.layoutselectCards or compact list. Cards suits landing pages; compact suits product pages.Compatibility notes
The generated section is built for Online Store 2.0 Liquid themes. It uses product pickers, theme-editor settings, scoped CSS, and Shopify's /cart/add.js endpoint.
Dawn and Horizon are the intended baseline because they support Online Store 2.0 sections and standard cart behavior. Cart drawer refresh events still vary by theme, so verify the final store behavior instead of assuming every theme opens its drawer.
Headless storefronts, Hydrogen builds, and themes with heavily customized cart JavaScript need a different implementation path.
Limitations
- First available variant only: the free generated section does not include size, color, or option selectors.
- No automatic bundle discount: the section shows bundle copy and can add products together, but it does not create Shopify discounts or Functions logic.
- Manual savings label: the label is merchant copy. Shopify product compare-at prices are shown when present, but the text label is not calculated.
- Theme-specific cart refresh: if your cart drawer does not refresh after add-to-cart, the event name may need to be adapted to your theme.
- Use a duplicate theme first: publish only after testing product selection, add-to-cart, cart count, and mobile layout.
Need the production version?
We can fit this to your theme, add variant selectors, bundle discounts, drawer integration, quantity rules, and final QA on your duplicate theme. Scoped build, no retainer.