Product Bundle

Frequently bought together

Total: $226.80 Regular price: $252.00

You save 10% when you buy all three.

@use 'sprucecss/scss/spruce' as *;

.product-bundle {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  padding: spacer-clamp('m', 'l');
  @include layout-stack('m');

  &__title {
    font-size: font-size('h4');
    margin: 0;
  }

  &__images {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('s');
    // The parent's layout-stack sets the margins.
    list-style: none;
    padding: 0;

    // The items sit side by side: clear the stack margins of the base list style.
    > li {
      margin-block: 0;
      padding-block: 0;
    }
  }

  &__media {
    align-items: center;
    display: flex;
    gap: spacer('s');

    img {
      aspect-ratio: 1;
      background-color: color('surface-muted');
      border-radius: radius('md');
      inline-size: clamp(4rem, 20vi, 6rem);
      object-fit: cover;
      @include transition($property: 'opacity, filter');
    }

    & + &::before {
      color: color('muted');
      content: '+';
      font-size: font-size('h4');
      font-weight: 300;
    }

    &--off img {
      filter: grayscale(1);
      opacity: 0.35;
    }
  }

  &__form {
    display: grid;
    gap: spacer('m');

    @include breakpoint('md') {
      align-items: end;
      grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
    }
  }

  &__items {
    @include clear-list;
    @include layout-stack('xs');
  }

  &__item {
    align-items: center;
    display: flex;
    gap: spacer('s');
    justify-content: space-between;


  }

  // The main product is always part of the bundle.
  &__fixed {
    align-items: center;
    color: color('heading');
    display: flex;
    gap: spacer('xs');

    svg {
      block-size: 1rem;
      color: color('success', 'alert');
      flex-shrink: 0;
      inline-size: 1rem;
    }
  }

  &__price {
    color: color('heading');
    font-variant-numeric: tabular-nums;
    font-weight: 600;
  }

  &__summary {
    @include layout-stack('xs');
  }

  &__total {
    align-items: baseline;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
    margin: 0;

    strong {
      color: color('heading');
      font-size: font-size('h4');
    }

    s {
      color: color('muted');
    }
  }

  &__note {
    color: color('success', 'alert');
    font-size: config('font-size-sm', $typography);
    font-weight: 600;
  }
}
<section class="product-bundle" aria-labelledby="product-bundle-title" data-product-bundle data-discount="0.1">
    <h2 class="product-bundle__title" id="product-bundle-title">Frequently bought together</h2>
    <ul class="product-bundle__images" role="list" aria-hidden="true">
        <li class="product-bundle__media" data-bundle-media="heritage">
            <img src="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
        </li>
        <li class="product-bundle__media" data-bundle-media="bottle">
            <img src="https://images.unsplash.com/photo-1602143407151-7111542de6e8?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
        </li>
        <li class="product-bundle__media" data-bundle-media="pouch">
            <img src="https://images.unsplash.com/photo-1622560481156-01fc7e1693e6?auto=format&fit=crop&w=240&h=240&q=60" alt="" width="120" height="120" loading="lazy">
        </li>
    </ul>
    <form class="product-bundle__form" action="#" method="post">
        <ul class="product-bundle__items" role="list">
            <li class="product-bundle__item">
                <input type="hidden" name="items[]" value="heritage">
                <span class="product-bundle__fixed" data-bundle-item="heritage" data-price="189" data-fixed>
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>
                    <span><strong>This item:</strong> Heritage Backpack</span>
                </span>
                <span class="product-bundle__price">$189.00</span>
            </li>
            <li class="product-bundle__item">
                <label class="form-check">
                    <input class="form-check__control" type="checkbox" name="items[]" value="bottle" checked data-bundle-item="bottle" data-price="24">
                    <span class="form-label form-check__label"><a href="#">Steel Water Bottle</a></span>
                </label>
                <span class="product-bundle__price">$24.00</span>
            </li>
            <li class="product-bundle__item">
                <label class="form-check">
                    <input class="form-check__control" type="checkbox" name="items[]" value="pouch" checked data-bundle-item="pouch" data-price="39">
                    <span class="form-label form-check__label"><a href="#">Leather Tech Pouch</a></span>
                </label>
                <span class="product-bundle__price">$39.00</span>
            </li>
        </ul>
        <div class="product-bundle__summary">
            <p class="product-bundle__total">
                Total: <strong data-bundle-total>$226.80</strong>
                <s data-bundle-full><span class="sr-only">Regular price:</span> $252.00</s>
            </p>
            <p class="product-bundle__note" data-bundle-note>You save 10% when you buy all three.</p>
            <button class="btn btn--primary" type="submit" data-bundle-submit>Add all three to cart</button>
        </div>
        <p class="sr-only" aria-live="polite" data-bundle-live></p>
    </form>
</section>
(() => {
  const words = ['zero', 'one', 'two', 'three', 'four', 'five'];

  document.querySelectorAll('[data-product-bundle]').forEach((bundle) => {
    const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: 'USD' });
    const discount = Number(bundle.dataset.discount || 0);
    const items = [...bundle.querySelectorAll('[data-bundle-item]')];
    const total = bundle.querySelector('[data-bundle-total]');
    const full = bundle.querySelector('[data-bundle-full]');
    const note = bundle.querySelector('[data-bundle-note]');
    const submit = bundle.querySelector('[data-bundle-submit]');
    const live = bundle.querySelector('[data-bundle-live]');

    const update = (announce = true) => {
      const selected = items.filter((item) => item.checked || item.hasAttribute('data-fixed'));
      const sum = selected.reduce((value, item) => value + Number(item.dataset.price), 0);
      // The discount only applies to the complete bundle.
      const complete = selected.length === items.length;
      const price = complete ? sum * (1 - discount) : sum;
      const count = words[selected.length] || selected.length;

      items.forEach((item) => {
        bundle.querySelector(`[data-bundle-media="${item.dataset.bundleItem}"]`).classList.toggle('product-bundle__media--off', !item.checked && !item.hasAttribute('data-fixed'));
      });

      total.textContent = format.format(price);
      full.hidden = !complete;
      full.lastChild.textContent = ` ${format.format(sum)}`;
      note.hidden = !complete;
      submit.textContent = selected.length === 1 ? 'Add to cart' : complete ? `Add all ${count} to cart` : `Add ${count} to cart`;

      if (announce) {
        live.textContent = `Total: ${format.format(price)}${complete ? `, ${discount * 100}% bundle discount included` : ''}.`;
      }
    };

    bundle.addEventListener('change', update);
    update(false);
  });
})();

Product Bundle

A "Frequently bought together" bundle: the product and its add-ons with checkboxes, a live total with a bundle discount and one button that adds them all.

Anatomy

  • .product-bundle__images: the product images separated by "+" signs; an unchecked item's image is dimmed (__media--off).
  • .product-bundle__items: a checkbox per product with its price. The main product is always included: it has a check icon instead of a checkbox (__fixed), and a hidden input submits it.
  • .product-bundle__summary: the total, the regular price, the savings note and the submit button.

Accessibility and Usability

  • The image row is decorative (aria-hidden), because the checkbox list carries the same information as text.
  • The main product is not a disabled checkbox (those are skipped by the keyboard and easy to misread as unavailable); it's plain text starting with "This item:".
  • The new total is announced when an item is toggled ("Total: $226.80, 10% bundle discount included"), and the button says how many products it adds.
  • The regular price has a hidden "Regular price:" label, because the strikethrough isn't announced.

Technical Details

  • Every checkbox has its data-price; the bundle discount is data-discount on the wrapper (0.1 is 10%) and only applies when every item is selected. Calculate the real price on the server.
  • Without JavaScript, the form posts the selected items (items[]), and the server-rendered total stays as it is.
  • On wide screens, the list and the summary sit side by side (md breakpoint).

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.