@use 'sprucecss/scss/spruce' as *;
.product-details {
@include layout-stack('m');
&__heading,
&__pricing {
@include layout-stack('xxs');
}
&__brand {
font-size: config('font-size-sm', $typography);
font-weight: 700;
letter-spacing: 0.04em;
margin: 0;
text-transform: uppercase;
a {
color: color('muted');
text-decoration: none;
}
}
&__title {
font-size: font-size('h2');
}
&__rating {
align-items: center;
color: color('muted');
display: inline-flex;
font-size: config('font-size-sm', $typography);
gap: spacer('xs');
text-decoration: none;
&:hover {
color: color('heading');
text-decoration: underline;
}
}
&__stars {
color: color('warning', 'alert');
display: inline-flex;
}
&__star {
block-size: 1rem;
inline-size: 1rem;
&--half {
mask: linear-gradient(90deg, #000 60%, hsl(0deg 0% 0% / 25%) 60%);
}
}
&__price {
align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: spacer('xs');
margin: 0;
}
&__current {
color: color('heading');
font-family: config('font-family-heading', $typography);
font-size: font-size('h3');
font-weight: 700;
}
&__compare {
color: color('muted');
}
&__save {
background-color: color-alpha('danger', 0.12, 'alert');
border-radius: radius('pill');
color: color('heading');
font-size: 0.75rem;
font-weight: 700;
padding: 0.25em 0.65em;
}
&__note {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
&__option {
border: 0;
margin-inline: 0;
padding: 0;
@include layout-stack('xs');
}
&__legend {
color: color('heading');
font-size: config('font-size-base', $typography);
font-weight: 700;
margin-block-end: spacer('xs');
padding: 0;
span {
color: color('text');
font-weight: 400;
}
}
&__swatches,
&__sizes {
display: flex;
flex-wrap: wrap;
gap: spacer('xs');
}
&__swatch,
&__size {
cursor: pointer;
position: relative;
input {
appearance: none;
cursor: pointer;
inset: 0;
margin: 0;
opacity: 0;
position: absolute;
}
}
&__swatch {
aspect-ratio: 1;
background-color: var(--swatch);
border: 2px solid color('surface');
border-radius: radius('pill');
box-shadow: 0 0 0 1px color('border');
inline-size: 2.25rem;
@include transition($property: 'box-shadow');
input {
border-radius: inherit;
}
&:has(:checked) {
box-shadow: 0 0 0 2px color('heading');
}
&:has(:focus-visible) {
outline: 2px solid color('focus-ring');
outline-offset: 3px;
}
}
&__size {
> span {
border: config('border-width-base', $display) solid color('border-strong');
border-radius: radius('md');
color: color('heading');
display: block;
font-weight: 600;
min-inline-size: 4rem;
padding: spacer('xs') spacer('s');
text-align: center;
@include transition;
}
&:hover > span {
border-color: color('heading');
}
&:has(:checked) > span {
background-color: color('heading');
border-color: color('heading');
color: color('surface');
}
&:has(:focus-visible) > span {
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
&:has(:disabled) {
cursor: not-allowed;
> span {
background: linear-gradient(to top left, transparent calc(50% - 1px), color('border') 50%, transparent calc(50% + 1px));
color: color('muted');
text-decoration: line-through;
}
input {
cursor: not-allowed;
}
}
}
&__guide {
display: inline-block;
font-size: config('font-size-sm', $typography);
}
&__stock {
align-items: center;
display: flex;
font-size: config('font-size-sm', $typography);
font-weight: 600;
gap: spacer('xs');
&::before {
aspect-ratio: 1;
background-color: color('success', 'alert');
border-radius: 50%;
content: '';
inline-size: 0.5rem;
}
&--low::before {
background-color: color('warning', 'alert');
}
&--out::before {
background-color: color('danger', 'alert');
}
}
&__buy {
display: flex;
gap: spacer('xs');
}
&__quantity {
flex: 0 0 5rem;
}
&__add {
flex-grow: 1;
}
&__wishlist[aria-pressed='true'] {
color: color('danger', 'alert');
svg {
fill: currentColor;
}
}
&__perks {
border-block: config('border-width-base', $display) solid color('border');
color: color('text');
font-size: config('font-size-sm', $typography);
list-style: none;
padding: 0;
padding-block: spacer('s');
@include layout-stack('xxs');
li {
align-items: center;
display: flex;
gap: spacer('xs');
&::before {
block-size: 0.6rem;
border-bottom: 2px solid color('success', 'alert');
border-right: 2px solid color('success', 'alert');
content: '';
inline-size: 0.35rem;
margin-inline: 0.2rem 0.25rem;
rotate: 45deg;
}
}
}
&__info {
@include layout-stack('xs');
details p {
color: color('text');
font-size: config('font-size-sm', $typography);
}
}
}
[dir='rtl'] .product-details__star--half {
mask: linear-gradient(-90deg, #000 60%, hsl(0deg 0% 0% / 25%) 60%);
}
<form class="product-details" action="#" method="post" data-product-details data-currency="USD" data-variants='{"20l": {"price": 169, "stock": 12}, "26l": {"price": 189, "stock": 3}, "32l": {"price": 209, "stock": 0}}'>
<div class="product-details__heading">
<p class="product-details__brand"><a href="#">Northbound Supply</a></p>
<h2 class="product-details__title">Heritage Backpack</h2>
<a class="product-details__rating" href="#reviews">
<span class="product-details__stars"><svg class="product-details__star" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.5 14.9 8.6l6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5-4.9-4.6 6.6-.8z"/></svg><svg class="product-details__star" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.5 14.9 8.6l6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5-4.9-4.6 6.6-.8z"/></svg><svg class="product-details__star" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.5 14.9 8.6l6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5-4.9-4.6 6.6-.8z"/></svg><svg class="product-details__star" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.5 14.9 8.6l6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5-4.9-4.6 6.6-.8z"/></svg><svg class="product-details__star product-details__star--half" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.5 14.9 8.6l6.6.8-4.9 4.6 1.3 6.5L12 17.3l-5.9 3.2 1.3-6.5-4.9-4.6 6.6-.8z"/></svg></span>
<span class="sr-only">Rated 4.6 out of 5,</span>
128 reviews
</a>
</div>
<div class="product-details__pricing">
<p class="product-details__price">
<span class="sr-only">Sale price:</span>
<span class="product-details__current" data-product-price>$189</span>
<s class="product-details__compare"><span class="sr-only">Regular price:</span> $229</s>
<span class="product-details__save">Save 17%</span>
</p>
<p class="product-details__note">Taxes included. <a href="#">Shipping</a> calculated at checkout.</p>
</div>
<fieldset class="product-details__option">
<legend class="product-details__legend">Color: <span data-option-label>Forest green</span></legend>
<div class="product-details__swatches">
<label class="product-details__swatch" style="--swatch: hsl(150deg 30% 30%);">
<input type="radio" name="color" value="forest-green" checked>
<span class="sr-only">Forest green</span>
</label>
<label class="product-details__swatch" style="--swatch: hsl(25deg 55% 40%);">
<input type="radio" name="color" value="cognac">
<span class="sr-only">Cognac</span>
</label>
<label class="product-details__swatch" style="--swatch: hsl(0deg 0% 12%);">
<input type="radio" name="color" value="black">
<span class="sr-only">Black</span>
</label>
</div>
</fieldset>
<fieldset class="product-details__option">
<legend class="product-details__legend">Size: <span data-option-label>26 L</span></legend>
<div class="product-details__sizes">
<label class="product-details__size">
<input type="radio" name="size" value="20l">
<span>20 L</span>
</label>
<label class="product-details__size">
<input type="radio" name="size" value="26l" checked>
<span>26 L</span>
</label>
<label class="product-details__size">
<input type="radio" name="size" value="32l" disabled>
<span>32 L <span class="sr-only">(sold out)</span></span>
</label>
</div>
<a class="product-details__guide" href="#">Size guide</a>
</fieldset>
<p class="product-details__stock product-details__stock--low" data-product-stock>Only 3 left in stock</p>
<div class="product-details__buy">
<div class="product-details__quantity">
<label class="sr-only" for="product-quantity">Quantity</label>
<input class="form-control form-control--lg" id="product-quantity" name="quantity" type="number" min="1" max="3" value="1" inputmode="numeric" data-product-quantity>
</div>
<button class="btn btn--primary btn--lg product-details__add" type="submit" data-product-add>Add to cart</button>
<button class="btn btn--outline-secondary btn--lg btn--icon product-details__wishlist" type="button" aria-pressed="false" data-product-wishlist hidden>
<svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/></svg>
<span class="sr-only">Save to wishlist</span>
</button>
</div>
<p class="sr-only" aria-live="polite" data-product-live></p>
<ul class="product-details__perks" role="list">
<li>Free shipping on orders over $100</li>
<li>Free returns within 30 days</li>
<li>2-year warranty</li>
</ul>
<div class="product-details__info">
<details>
<summary>Materials and care</summary>
<p>Waxed cotton canvas with vegetable-tanned leather straps and solid brass hardware. Wipe clean with a damp cloth; re-wax once a year.</p>
</details>
<details>
<summary>Shipping and returns</summary>
<p>Ships in 1–2 business days. Return it unused within 30 days for a full refund.</p>
</details>
</div>
</form>
(() => {
document.querySelectorAll('[data-product-details]').forEach((form) => {
const variants = JSON.parse(form.dataset.variants || '{}');
const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: form.dataset.currency || 'USD', maximumFractionDigits: 0 });
const price = form.querySelector('[data-product-price]');
const stock = form.querySelector('[data-product-stock]');
const quantity = form.querySelector('[data-product-quantity]');
const add = form.querySelector('[data-product-add]');
const wishlist = form.querySelector('[data-product-wishlist]');
const live = form.querySelector('[data-product-live]');
const update = () => {
form.querySelectorAll('.product-details__option').forEach((option) => {
const checked = option.querySelector('input:checked');
if (checked) {
option.querySelector('[data-option-label]').textContent = checked.closest('label').textContent.trim();
}
});
const variant = variants[form.elements.size.value];
if (!variant) {
return;
}
const low = variant.stock > 0 && variant.stock <= 5;
price.textContent = format.format(variant.price);
stock.textContent = variant.stock === 0 ? 'Sold out' : low ? `Only ${variant.stock} left in stock` : 'In stock, ready to ship';
stock.classList.toggle('product-details__stock--low', low);
stock.classList.toggle('product-details__stock--out', variant.stock === 0);
quantity.max = variant.stock;
quantity.value = Math.min(Number(quantity.value), Math.max(variant.stock, 1));
add.disabled = variant.stock === 0;
add.textContent = `Add to cart · ${format.format(variant.price * Number(quantity.value))}`;
};
form.addEventListener('change', update);
form.addEventListener('input', update);
form.addEventListener('submit', (event) => {
event.preventDefault();
form.dispatchEvent(new CustomEvent('cart:add', { bubbles: true, detail: { data: new FormData(form) } }));
live.textContent = `${quantity.value} × ${form.querySelector('.product-details__title').textContent} added to your cart.`;
});
wishlist.hidden = false;
wishlist.addEventListener('click', () => {
const pressed = wishlist.getAttribute('aria-pressed') !== 'true';
wishlist.setAttribute('aria-pressed', String(pressed));
live.textContent = pressed ? 'Saved to your wishlist.' : 'Removed from your wishlist.';
});
update();
});
})();