# Product bundle: free Shopify section

> Buy-it-together bundle of two or three products with variant menus, a live total and one Add all to cart button through the Cart AJAX API.

Product bundle is a free Shopify section that offers two or three products you pick as a set to buy together, each with its image, price and variant menu, a running total and one Add all to cart button. Shoppers can leave a product out with its checkbox; the button adds the chosen variants in one request to Shopify's Cart AJAX API. The section shows normal prices: any bundle discount is an automatic discount you set up in Shopify. It works in Online Store 2.0 themes without an app.

- **Category:** Product page
- **Use on:** Product, Landing pages, Home, Any page
- **Reads:** Up to three products you pick
- **Size:** 27 KB in 1 file, with a small script
- **License:** MIT, © 2026 Lintel
- **Page:** https://lintelagency.com/resources/shopify-sections/product-bundle
- **Live demo:** https://lintelagency.com/resources/shopify-sections/demo/lib-product-bundle.html
- **Added:** 2026-11-02

## What it’s for

- Frequently bought together set under a product, such as a carafe with cups and a tray
- A starter kit or gift set on a landing page that adds every piece in one click
- Pairing an accessory with the main product without a bundle app

## Editor settings

In the theme editor the section is called “Lib · Product bundle”. IDs, types, labels and defaults come from the section’s schema; the labels are what the theme editor shows.

### Content

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `heading` | `inline_richtext` | Heading | `Better <em>together</em>` |  |
| `heading_tag` | `select` | Heading tag | h2 | `h1`, `h2`, `h3`, `p` |
| `heading_size` | `select` | Heading size | medium | `small`, `medium`, `large` |
| `text` | `richtext` | Text | `<p>Pieces that are made to be used together.</p>` |  |
| `discount_note` | `text` | Discount note | – |  |

### Words

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `button_label` | `text` | Button | Add all to cart |  |
| `total_label` | `text` | Total | Total |  |
| `include_label` | `text` | Screen reader: before each product's checkbox | Include |  |
| `variant_label` | `text` | Screen reader: variant menu | Options |  |
| `sold_out_label` | `text` | Sold out | Sold out |  |
| `sale_label` | `text` | Screen reader: sale price | Sale price |  |
| `regular_label` | `text` | Screen reader: regular price | Regular price |  |
| `added_message` | `text` | Message when added | Added to your cart. |  |
| `error_message` | `text` | Message when adding fails | These products could not be added. Please try again. |  |
| `empty_message` | `text` | Message when nothing is chosen | Choose at least one product. |  |
| `after_add` | `select` | After adding | cart | `cart`, `stay` |

### Layout

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `image_ratio` | `select` | Image shape | square | `square`, `portrait` |
| `section_width` | `select` | Width | page | `page`, `full` |
| `text_alignment` | `text_alignment` | Heading alignment on desktop | left |  |
| `text_alignment_mobile` | `select` | Heading alignment on mobile | left | `left`, `center`, `right` |

### Colors

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `color_bg` | `color` | Background | #FFFDF9 |  |
| `color_bg_gradient` | `color_background` | Background gradient | – |  |
| `color_text` | `color` | Text | #1C1917 |  |
| `color_accent` | `color` | Button and checkboxes | #1C1917 |  |
| `color_accent_text` | `color` | Button text | #FFFDF9 |  |
| `color_card` | `color` | Card and total background | #F5F2EC |  |
| `color_sale` | `color` | Sale price and discount note | #B4432F |  |

### Spacing and visibility

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `padding_top` | `range` | Top padding on desktop | `72` | 0–200 px, step 4 |
| `padding_bottom` | `range` | Bottom padding on desktop | `72` | 0–200 px, step 4 |
| `padding_top_mobile` | `range` | Top padding on mobile | `44` | 0–160 px, step 4 |
| `padding_bottom_mobile` | `range` | Bottom padding on mobile | `44` | 0–160 px, step 4 |
| `show_on` | `select` | Show on | all | `all`, `desktop`, `mobile` |
| `anchor_id` | `text` | Anchor ID | – |  |

### Motion

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `animate` | `checkbox` | Fade in the products on load | `false` |  |

## Blocks

Up to 3 blocks in total.

### Product (`product`, up to 3)

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `product` | `product` | Product | – |  |
| `quantity` | `range` | Quantity | `1` | 1–5, step 1 |
| `checked` | `checkbox` | Included by default | `true` |  |

## Install

### In Shopify admin

1. Work on a copy, not your live theme: in Shopify admin go to **Online Store > Themes**, open the **…** menu on your current theme and choose **Duplicate**.
2. On the copy, open the **…** menu and choose **Edit code**.
3. In the `sections` folder, add a new file named `lib-product-bundle.liquid` and paste the contents of `sections/lib-product-bundle.liquid` from the source below. Save.
4. Open **Customize** on the copy, choose **Add section** and pick **Lib · Product bundle**. Replace the sample content with your own and set the colors.
5. Preview the copy, then publish it when it looks right.

### With Shopify CLI

From your local theme folder, download the files and push only them to an unpublished theme (find its ID with `shopify theme list`):

```sh
curl -fsSL -o sections/lib-product-bundle.liquid https://lintelagency.com/resources/shopify-sections/files/lib-product-bundle.liquid
shopify theme push --theme <unpublished-theme-id> --only sections/lib-product-bundle.liquid
```

Never push to the live theme: always pass the ID of an unpublished theme and never use `--live` or `--allow-live`. Then add the section in the theme editor as above.

## Files

- [sections/lib-product-bundle.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-product-bundle.liquid): goes in `sections/`

## Questions

### Does the section give a bundle discount?

No. It adds the products at their normal prices. To discount the set, create an automatic discount in Shopify (Discounts), for example a percentage off when these products are in the cart, and describe it in the section's discount note. The note is only text.

### How are the products added to the cart?

With JavaScript, one request to /cart/add.js with every chosen variant and quantity; then the shopper goes to the cart or stays on the page with a message, as you choose. A cart:refresh event lets themes that listen for it update their cart count. Without JavaScript the form posts every available product to /cart/add.

### What happens with sold-out products?

A sold-out product is shown greyed with a Sold out label and never added. Sold-out variants stay in the menu but cannot be chosen.

### Can the shopper change variants and quantities?

Each product with options has a variant menu, and the price and total follow the chosen variant. The quantity per product is set by you in the block (for example two cups); shoppers include or leave out products with the checkboxes.

### Is the total in the store's currency format?

Yes. It is formatted from a price rendered with Shopify's money filter, so the currency sign, separators and decimals match the rest of the store.

## Source

### sections/lib-product-bundle.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{%- comment -%}
  Product bundle ("buy it together"): two or three products you pick, each with its image, price
  and variant menu, a running total and one button that adds them all to the cart through
  Shopify's Cart AJAX API (/cart/add.js). Shoppers can leave a product out with its checkbox.
  The section only displays prices: a bundle discount has to be set up in Shopify (Discounts),
  and the optional discount note is text you type. Without JavaScript the form posts every
  available product to /cart/add. Sold-out products are shown but never added.
  Depends on: nothing.
{%- endcomment -%}

{%- liquid
  assign s = section.settings
  assign heading_tag = s.heading_tag | default: 'h2'
  assign uid = 'LibBundle-' | append: section.id
-%}

<div
  class="lib-bundle lib-bundle--{{ s.section_width }}"
  {% if s.anchor_id != blank %}
    id="{{ s.anchor_id | handleize }}"
  {% endif %}
  data-show="{{ s.show_on }}"
  data-ratio="{{ s.image_ratio }}"
  {% if s.animate %}
    data-animate
  {% endif %}
  style="
    --lib-bg: {{ s.color_bg }};
    {% if s.color_bg_gradient != blank %}--lib-bg-image: {{ s.color_bg_gradient }};{% endif %}
    --lib-text: {{ s.color_text }};
    --lib-accent: {{ s.color_accent }};
    --lib-accent-text: {{ s.color_accent_text }};
    --lib-card: {{ s.color_card }};
    --lib-sale: {{ s.color_sale }};
    --lib-pt: {{ s.padding_top }}px;
    --lib-pb: {{ s.padding_bottom }}px;
    --lib-pt-m: {{ s.padding_top_mobile }}px;
    --lib-pb-m: {{ s.padding_bottom_mobile }}px;
  "
>
  <div class="lib-bundle__inner">
    {%- if s.heading != blank or s.text != blank -%}
      <header class="lib-bundle__header" data-align="{{ s.text_alignment }}" data-align-m="{{ s.text_alignment_mobile }}">
        {%- if s.heading != blank -%}
          <{{ heading_tag }} class="lib-bundle__heading lib-bundle__heading--{{ s.heading_size }}" id="{{ uid }}-title">
            {{- s.heading -}}
          </{{ heading_tag }}>
        {%- endif -%}
        {%- if s.text != blank -%}
          <div class="lib-bundle__text">{{ s.text }}</div>
        {%- endif -%}
      </header>
    {%- endif -%}

    <lib-product-bundle
      data-after-add="{{ s.after_add }}"
      data-cart-url="{{ routes.cart_url }}"
      data-add-url="{{ routes.cart_add_url }}.js"
      data-money-sample="{{ 123456 | money | escape }}"
      data-msg-added="{{ s.added_message | escape }}"
      data-msg-error="{{ s.error_message | escape }}"
      data-msg-empty="{{ s.empty_message | escape }}"
    >
      <form action="{{ routes.cart_add_url }}" method="post" class="lib-bundle__form" novalidate>
        <ul class="lib-bundle__items" role="list">
          {%- for block in section.blocks -%}
            {%- liquid
              assign b = block.settings
              assign p = b.product
              if p == blank and request.design_mode == false
                continue
              endif
              assign item_id = uid | append: '-' | append: block.id
              assign v = p.selected_or_first_available_variant
              assign qty = b.quantity | default: 1
            -%}
            <li
              class="lib-bundle__item{% if p != blank and p.available == false %} is-sold-out{% endif %}"
              {% if p != blank and p.available %}
                data-item data-qty="{{ qty }}"
              {% endif %}
              {{ block.shopify_attributes }}
            >
              <div class="lib-bundle__media">
                {%- if p.featured_image != blank -%}
                  {{
                    p.featured_image
                    | image_url: width: 600
                    | image_tag: class: 'lib-bundle__img', loading: 'lazy', alt: p.featured_image.alt, widths: '200, 400, 600', sizes: '(min-width: 990px) 20vw, 30vw'
                  }}
                {%- else -%}
                  {{ 'product-1' | placeholder_svg_tag: 'lib-bundle__img lib-bundle__img--ph' }}
                {%- endif -%}
              </div>

              <div class="lib-bundle__body">
                {%- if p != blank and p.available -%}
                  <label class="lib-bundle__pick" hidden data-pick>
                    <input type="checkbox" class="lib-bundle__check" {% if b.checked %}checked{% endif %} data-include>
                    <span class="lib-bundle__sr">{{ s.include_label | escape }}</span>
                    <span class="lib-bundle__title">{{ p.title | escape }}</span>
                  </label>
                  <p class="lib-bundle__title lib-bundle__title--nojs" data-nojs-title>
                    {%- if qty > 1 %}{{ qty }} × {% endif %}{{ p.title | escape -}}
                  </p>
                {%- else -%}
                  <p class="lib-bundle__title">{{ p.title | default: 'Example product' | escape }}</p>
                {%- endif -%}
                {%- if qty > 1 and p.available -%}
                  <p class="lib-bundle__qty" hidden data-qty-label>× {{ qty }}</p>
                {%- endif -%}

                <p class="lib-bundle__price" data-price-wrap>
                  {%- if p == blank -%}
                    <span class="lib-bundle__now">{{ 4800 | money }}</span>
                  {%- elsif p.available == false -%}
                    <span class="lib-bundle__badge">{{ s.sold_out_label | escape }}</span>
                  {%- else -%}
                    {%- assign on_sale = false -%}
                    {%- if v.compare_at_price > v.price -%}{%- assign on_sale = true -%}{%- endif -%}
                    <span class="lib-bundle__sr" data-sale-sr {% unless on_sale %}hidden{% endunless %}>{{ s.sale_label | escape }}</span>
                    <span class="lib-bundle__now{% if on_sale %} is-sale{% endif %}" data-price>{{ v.price | money }}</span>
                    <span class="lib-bundle__sr" data-was-sr {% unless on_sale %}hidden{% endunless %}>{{ s.regular_label | escape }}</span>
                    <s class="lib-bundle__was" data-was>{% if on_sale %}{{ v.compare_at_price | money }}{% endif %}</s>
                  {%- endif -%}
                </p>

                {%- if p != blank and p.available -%}
                  {%- if p.has_only_default_variant -%}
                    <input type="hidden" name="items[][id]" value="{{ v.id }}" data-variant data-cents="{{ v.price }}">
                  {%- else -%}
                    <label class="lib-bundle__sr" for="{{ item_id }}-variant">{{ s.variant_label | escape }}: {{ p.title | escape }}</label>
                    <select class="lib-bundle__select" id="{{ item_id }}-variant" name="items[][id]" data-variant>
                      {%- for variant in p.variants -%}
                        <option
                          value="{{ variant.id }}"
                          data-cents="{{ variant.price }}"
                          data-price="{{ variant.price | money | escape }}"
                          data-compare="{% if variant.compare_at_price > variant.price %}{{ variant.compare_at_price | money | escape }}{% endif %}"
                          {% if variant.id == v.id %}selected{% endif %}
                          {% unless variant.available %}disabled{% endunless %}
                        >
                          {{- variant.title | escape }}{% unless variant.available %} ({{ s.sold_out_label | escape }}){% endunless -%}
                        </option>
                      {%- endfor -%}
                    </select>
                  {%- endif -%}
                  <input type="hidden" name="items[][quantity]" value="{{ qty }}">
                {%- endif -%}
              </div>
            </li>
          {%- endfor -%}
        </ul>

        <div class="lib-bundle__summary">
          {%- if s.discount_note != blank -%}
            <p class="lib-bundle__note">{{ s.discount_note | escape }}</p>
          {%- endif -%}
          <p class="lib-bundle__total" hidden data-total-wrap>
            <span>{{ s.total_label | escape }}</span>
            <strong data-total aria-live="polite"></strong>
          </p>
          <button type="submit" class="lib-bundle__button" data-submit>
            <span>{{ s.button_label | escape }}</span>
          </button>
          <p class="lib-bundle__status" role="status" data-status></p>
        </div>
      </form>
    </lib-product-bundle>
  </div>
</div>

{% stylesheet %}
  .lib-bundle {
    background-color: var(--lib-bg);
    background-image: var(--lib-bg-image, none);
    color: var(--lib-text);
    padding: var(--lib-pt) 1rem var(--lib-pb);
  }
  .lib-bundle__inner { max-width: 1200px; margin: 0 auto; display: grid; gap: clamp(1.5rem, 3vw, 2.5rem); }
  .lib-bundle--full .lib-bundle__inner { max-width: none; }
  @media (max-width: 749px) {
    .lib-bundle { padding-top: var(--lib-pt-m); padding-bottom: var(--lib-pb-m); }
    .lib-bundle[data-show='desktop'] { display: none; }
  }
  @media (min-width: 750px) {
    .lib-bundle[data-show='mobile'] { display: none; }
  }

  .lib-bundle__header { text-align: left; }
  .lib-bundle__header[data-align='center'] { text-align: center; }
  .lib-bundle__header[data-align='right'] { text-align: right; }
  @media (max-width: 749px) {
    .lib-bundle__header[data-align-m='left'] { text-align: left; }
    .lib-bundle__header[data-align-m='center'] { text-align: center; }
    .lib-bundle__header[data-align-m='right'] { text-align: right; }
  }
  .lib-bundle__heading { margin: 0; font-size: clamp(1.75rem, 3.2vw, 2.5rem); line-height: 1.1; letter-spacing: -0.02em; text-wrap: balance; }
  .lib-bundle__heading--small { font-size: clamp(1.4rem, 2.2vw, 1.75rem); }
  .lib-bundle__heading--large { font-size: clamp(2rem, 4.2vw, 3.25rem); }
  .lib-bundle__text { max-width: 40rem; margin-top: 0.75rem; font-size: 1.0625rem; line-height: 1.6; opacity: 0.8; }
  .lib-bundle__header[data-align='center'] .lib-bundle__text { margin-inline: auto; }
  .lib-bundle__header[data-align='right'] .lib-bundle__text { margin-inline-start: auto; }
  .lib-bundle__text > :first-child { margin-top: 0; }
  .lib-bundle__text > :last-child { margin-bottom: 0; }

  .lib-bundle__form { display: grid; gap: 1.5rem; }
  @media (min-width: 990px) {
    .lib-bundle__form { grid-template-columns: minmax(0, 1fr) 18rem; align-items: center; gap: 2.5rem; }
  }
  .lib-bundle__items { display: grid; gap: 0.75rem; margin: 0; padding: 0; list-style: none; }
  @media (min-width: 750px) {
    .lib-bundle__items { grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 2.75rem; }
    /* The plus sign between products. */
    .lib-bundle__item + .lib-bundle__item::before {
      content: '+';
      position: absolute;
      left: -1.375rem;
      top: 35%;
      translate: -50% -50%;
      font-size: 1.5rem;
      font-weight: 300;
      opacity: 0.55;
    }
  }
  .lib-bundle__item {
    position: relative;
    display: grid;
    grid-template-columns: 6rem minmax(0, 1fr);
    gap: 1rem;
    align-items: center;
    padding: 0.75rem;
    border-radius: 14px;
    background: var(--lib-card);
    transition: opacity 0.2s ease;
  }
  @media (min-width: 750px) {
    .lib-bundle__item { grid-template-columns: 1fr; align-items: start; align-content: start; padding: 0; background: none; }
  }
  .lib-bundle__item.is-out,
  .lib-bundle__item.is-sold-out { opacity: 0.5; }
  .lib-bundle__media { overflow: hidden; border-radius: 10px; aspect-ratio: 1; background: color-mix(in srgb, var(--lib-text) 6%, transparent); }
  .lib-bundle[data-ratio='portrait'] .lib-bundle__media { aspect-ratio: 4 / 5; }
  .lib-bundle__img { display: block; width: 100%; height: 100%; object-fit: cover; }
  .lib-bundle__img--ph { fill: color-mix(in srgb, var(--lib-text) 22%, transparent); }
  .lib-bundle__body { display: grid; gap: 0.35rem; min-width: 0; }
  .lib-bundle__pick { display: flex; align-items: flex-start; gap: 0.55rem; cursor: pointer; }
  .lib-bundle__pick[hidden], .lib-bundle__qty[hidden] { display: none; }
  .lib-bundle__check {
    flex: none;
    width: 1.25rem;
    height: 1.25rem;
    margin: 0.1rem 0 0;
    accent-color: var(--lib-accent);
    cursor: pointer;
  }
  .lib-bundle__check:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 2px; }
  .lib-bundle__title { margin: 0; font-weight: 600; line-height: 1.35; }
  .lib-bundle__qty { margin: 0; font-size: 0.875rem; opacity: 0.75; }
  .lib-bundle__price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.2rem 0.5rem; margin: 0; }
  .lib-bundle__now.is-sale { color: var(--lib-sale); font-weight: 600; }
  .lib-bundle__was { opacity: 0.6; font-size: 0.875em; }
  .lib-bundle__badge { font-size: 0.8125rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
  .lib-bundle__select {
    width: 100%;
    min-height: 2.5rem;
    margin-top: 0.25rem;
    padding: 0.4rem 0.6rem;
    border: 1px solid color-mix(in srgb, var(--lib-text) 35%, transparent);
    border-radius: 8px;
    background: var(--lib-bg);
    color: inherit;
    font: inherit;
    font-size: 0.9375rem;
  }
  .lib-bundle__select:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 1px; }

  .lib-bundle__summary {
    display: grid;
    gap: 0.85rem;
    padding: 1.5rem;
    border-radius: 16px;
    background: var(--lib-card);
  }
  .lib-bundle__note { margin: 0; font-size: 0.9375rem; font-weight: 600; color: var(--lib-sale); }
  .lib-bundle__total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin: 0; font-size: 1.0625rem; }
  .lib-bundle__total[hidden] { display: none; }
  .lib-bundle__total strong { font-size: 1.375rem; font-variant-numeric: tabular-nums; }
  .lib-bundle__button {
    display: inline-flex;
    justify-content: center;
    align-items: center;
    min-height: 3rem;
    padding: 0.75rem 1.5rem;
    border: 0;
    border-radius: 999px;
    background: var(--lib-accent);
    color: var(--lib-accent-text);
    font: inherit;
    font-weight: 600;
    cursor: pointer;
    transition: opacity 0.2s ease, transform 0.2s ease;
  }
  .lib-bundle__button:hover { opacity: 0.9; }
  .lib-bundle__button:focus-visible { outline: 2px solid var(--lib-text); outline-offset: 3px; }
  .lib-bundle__button[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
  .lib-bundle__status { margin: 0; font-size: 0.875rem; }
  .lib-bundle__status:empty { display: none; }
  .lib-bundle__sr {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .lib-bundle__sr[hidden] { display: none; }

  @media (prefers-reduced-motion: no-preference) {
    .lib-bundle[data-animate] .lib-bundle__item { animation: lib-bundle-in 0.6s ease-out both; }
    .lib-bundle[data-animate] .lib-bundle__item:nth-child(2) { animation-delay: 0.1s; }
    .lib-bundle[data-animate] .lib-bundle__item:nth-child(3) { animation-delay: 0.2s; }
  }
  @keyframes lib-bundle-in { from { opacity: 0; transform: translateY(10px); } }
{% endstylesheet %}

{% javascript %}
  if (!customElements.get('lib-product-bundle')) {
    customElements.define(
      'lib-product-bundle',
      class extends HTMLElement {
        connectedCallback() {
          if (this.form) return;
          this.form = this.querySelector('form');
          this.items = [...this.querySelectorAll('[data-item]')];
          this.status = this.querySelector('[data-status]');
          this.button = this.querySelector('[data-submit]');
          this.totalEl = this.querySelector('[data-total]');
          if (!this.form || !this.items.length) return;

          // With JavaScript, products can be left out: show the checkboxes, hide the plain titles.
          this.items.forEach((item) => {
            item.querySelector('[data-pick]')?.removeAttribute('hidden');
            item.querySelector('[data-qty-label]')?.removeAttribute('hidden');
            item.querySelector('[data-nojs-title]')?.remove();
            item.querySelector('[data-include]')?.addEventListener('change', () => this.update());
            const select = item.querySelector('select[data-variant]');
            if (select) select.addEventListener('change', () => this.showVariant(item, select));
          });
          this.querySelector('[data-total-wrap]')?.removeAttribute('hidden');
          this.form.addEventListener('submit', (event) => this.submit(event));
          this.update();
        }

        chosen() {
          return this.items.filter((item) => item.querySelector('[data-include]')?.checked);
        }

        cents(item) {
          const input = item.querySelector('[data-variant]');
          const source = input?.tagName === 'SELECT' ? input.selectedOptions[0] : input;
          return Number(source?.dataset.cents || 0) * Number(item.dataset.qty || 1);
        }

        // Formats cents like the store does. The section renders 123456 cents with Shopify's money
        // filter (data-money-sample, e.g. "$1,234.56" or "1.234,56 €"); the separators, decimals and
        // currency sign are read from that sample, so any money format works.
        money(cents) {
          const sample = this.dataset.moneySample || '';
          const match = sample.match(/1(\D?)234(\D)56|1(\D?)235/);
          if (!match) return (cents / 100).toFixed(2);
          const point = match[2] || '';
          const thousands = (point ? match[1] : match[3]) || '';
          const [whole, frac] = (cents / 100).toFixed(point ? 2 : 0).split('.');
          const number = whole.replace(/\B(?=(\d{3})+(?!\d))/g, thousands) + (frac ? point + frac : '');
          return sample.replace(match[0], number);
        }

        showVariant(item, select) {
          const option = select.selectedOptions[0];
          const price = item.querySelector('[data-price]');
          const was = item.querySelector('[data-was]');
          const onSale = Boolean(option.dataset.compare);
          if (price) {
            price.textContent = option.dataset.price;
            price.classList.toggle('is-sale', onSale);
          }
          if (was) was.textContent = option.dataset.compare || '';
          item.querySelector('[data-sale-sr]')?.toggleAttribute('hidden', !onSale);
          item.querySelector('[data-was-sr]')?.toggleAttribute('hidden', !onSale);
          this.update();
        }

        update() {
          const chosen = this.chosen();
          this.items.forEach((item) => item.classList.toggle('is-out', !chosen.includes(item)));
          if (this.totalEl) this.totalEl.textContent = this.money(chosen.reduce((sum, item) => sum + this.cents(item), 0));
          // aria-disabled keeps the button focusable, so the "choose a product" message can be heard.
          this.button?.setAttribute('aria-disabled', String(chosen.length === 0));
        }

        say(message) {
          if (!this.status) return;
          this.status.textContent = '';
          requestAnimationFrame(() => {
            this.status.textContent = message;
          });
        }

        async submit(event) {
          event.preventDefault();
          const chosen = this.chosen();
          if (!chosen.length) {
            this.say(this.dataset.msgEmpty);
            return;
          }
          const items = chosen.map((item) => ({
            id: Number(item.querySelector('[data-variant]').value),
            quantity: Number(item.dataset.qty || 1),
          }));
          this.button.setAttribute('aria-busy', 'true');
          try {
            const response = await fetch(this.dataset.addUrl, {
              method: 'POST',
              headers: {'Content-Type': 'application/json', Accept: 'application/json'},
              body: JSON.stringify({items}),
            });
            const body = await response.json().catch(() => ({}));
            if (!response.ok) throw new Error(body.description || '');
            document.dispatchEvent(new CustomEvent('cart:refresh', {bubbles: true, detail: {items: body.items || items}}));
            if (this.dataset.afterAdd === 'cart') {
              window.location.href = this.dataset.cartUrl;
              return;
            }
            this.say(this.dataset.msgAdded);
          } catch (error) {
            // Shopify's own message (e.g. a quantity limit) is more useful than the generic one.
            this.say(error.message || this.dataset.msgError);
          } finally {
            this.button.removeAttribute('aria-busy');
          }
        }
      }
    );
  }
{% endjavascript %}

{% schema %}
{
  "name": "Lib · Product bundle",
  "tag": "section",
  "class": "lib-section",
  "max_blocks": 3,
  "settings": [
    {
      "type": "paragraph",
      "content": "The section adds the products at their normal prices. For a bundle discount, create an automatic discount in Shopify (Discounts) for these products; the note below only describes it."
    },
    { "type": "header", "content": "Content" },
    { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Better <em>together</em>" },
    {
      "type": "select",
      "id": "heading_tag",
      "label": "Heading tag",
      "info": "Use H1 only when this is the page's main heading.",
      "options": [
        { "value": "h1", "label": "H1" },
        { "value": "h2", "label": "H2" },
        { "value": "h3", "label": "H3" },
        { "value": "p", "label": "Paragraph" }
      ],
      "default": "h2"
    },
    {
      "type": "select",
      "id": "heading_size",
      "label": "Heading size",
      "options": [
        { "value": "small", "label": "Small" },
        { "value": "medium", "label": "Medium" },
        { "value": "large", "label": "Large" }
      ],
      "default": "medium"
    },
    { "type": "richtext", "id": "text", "label": "Text", "default": "<p>Pieces that are made to be used together.</p>" },
    { "type": "text", "id": "discount_note", "label": "Discount note", "info": "Optional, e.g. 10% off when bought together. Only text: set up the discount itself in Shopify." },

    { "type": "header", "content": "Words" },
    { "type": "text", "id": "button_label", "label": "Button", "default": "Add all to cart" },
    { "type": "text", "id": "total_label", "label": "Total", "default": "Total" },
    { "type": "text", "id": "include_label", "label": "Screen reader: before each product's checkbox", "default": "Include" },
    { "type": "text", "id": "variant_label", "label": "Screen reader: variant menu", "default": "Options" },
    { "type": "text", "id": "sold_out_label", "label": "Sold out", "default": "Sold out" },
    { "type": "text", "id": "sale_label", "label": "Screen reader: sale price", "default": "Sale price" },
    { "type": "text", "id": "regular_label", "label": "Screen reader: regular price", "default": "Regular price" },
    { "type": "text", "id": "added_message", "label": "Message when added", "default": "Added to your cart." },
    { "type": "text", "id": "error_message", "label": "Message when adding fails", "default": "These products could not be added. Please try again." },
    { "type": "text", "id": "empty_message", "label": "Message when nothing is chosen", "default": "Choose at least one product." },
    {
      "type": "select",
      "id": "after_add",
      "label": "After adding",
      "info": "Staying on the page sends a cart:refresh event that many themes use to update the cart count.",
      "options": [
        { "value": "cart", "label": "Go to the cart" },
        { "value": "stay", "label": "Stay and show the message" }
      ],
      "default": "cart"
    },

    { "type": "header", "content": "Layout" },
    {
      "type": "select",
      "id": "image_ratio",
      "label": "Image shape",
      "options": [
        { "value": "square", "label": "Square" },
        { "value": "portrait", "label": "Portrait (4:5)" }
      ],
      "default": "square"
    },
    {
      "type": "select",
      "id": "section_width",
      "label": "Width",
      "options": [
        { "value": "page", "label": "Page width" },
        { "value": "full", "label": "Full width" }
      ],
      "default": "page"
    },
    { "type": "text_alignment", "id": "text_alignment", "label": "Heading alignment on desktop", "default": "left" },
    {
      "type": "select",
      "id": "text_alignment_mobile",
      "label": "Heading alignment on mobile",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" },
        { "value": "right", "label": "Right" }
      ],
      "default": "left"
    },

    { "type": "header", "content": "Colors" },
    { "type": "color", "id": "color_bg", "label": "Background", "default": "#FFFDF9" },
    { "type": "color_background", "id": "color_bg_gradient", "label": "Background gradient", "info": "Optional. Covers the background color." },
    { "type": "color", "id": "color_text", "label": "Text", "default": "#1C1917" },
    { "type": "color", "id": "color_accent", "label": "Button and checkboxes", "default": "#1C1917" },
    { "type": "color", "id": "color_accent_text", "label": "Button text", "default": "#FFFDF9" },
    { "type": "color", "id": "color_card", "label": "Card and total background", "default": "#F5F2EC" },
    { "type": "color", "id": "color_sale", "label": "Sale price and discount note", "default": "#B4432F" },

    { "type": "header", "content": "Spacing and visibility" },
    { "type": "range", "id": "padding_top", "label": "Top padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 72 },
    { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 72 },
    { "type": "range", "id": "padding_top_mobile", "label": "Top padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 44 },
    { "type": "range", "id": "padding_bottom_mobile", "label": "Bottom padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 44 },
    {
      "type": "select",
      "id": "show_on",
      "label": "Show on",
      "options": [
        { "value": "all", "label": "All devices" },
        { "value": "desktop", "label": "Desktop only" },
        { "value": "mobile", "label": "Mobile only" }
      ],
      "default": "all"
    },
    { "type": "text", "id": "anchor_id", "label": "Anchor ID", "info": "Optional. Link to this section with #your-id." },

    { "type": "header", "content": "Motion" },
    { "type": "checkbox", "id": "animate", "label": "Fade in the products on load", "info": "Always off for visitors who prefer reduced motion.", "default": false }
  ],
  "blocks": [
    {
      "type": "product",
      "name": "Product",
      "limit": 3,
      "settings": [
        { "type": "product", "id": "product", "label": "Product" },
        { "type": "range", "id": "quantity", "label": "Quantity", "min": 1, "max": 5, "step": 1, "default": 1 },
        { "type": "checkbox", "id": "checked", "label": "Included by default", "default": true }
      ]
    }
  ],
  "presets": [
    {
      "name": "Lib · Product bundle",
      "blocks": [
        { "type": "product" },
        { "type": "product" },
        { "type": "product" }
      ]
    }
  ]
}
{% endschema %}
```

## License

MIT License, © 2026 Lintel. Free to use, change and ship in your own store or in client work; keep the license line at the top of each file.
