Frequently bought together
@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');
list-style: none;
padding: 0;
> 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;
}
&__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);
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);
});
})();