Product Details

Sale price: $189 Regular price: $229 Save 17%

Taxes included. Shipping calculated at checkout.

Color: Forest green
Size: 26 L
Size guide

Only 3 left in stock

  • Free shipping on orders over $100
  • Free returns within 30 days
  • 2-year warranty
Materials and care

Waxed cotton canvas with vegetable-tanned leather straps and solid brass hardware. Wipe clean with a damp cloth; re-wax once a year.

Shipping and returns

Ships in 1–2 business days. Return it unused within 30 days for a full refund.

@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;
      // Invisible but focusable and clickable; the label shows the state.
      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);
    // The parent's layout-stack sets the margins.
    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;
        // A check mark is not mirrored in right-to-left languages, so it uses physical borders.
        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 = () => {
      // Show the selected value next to every option legend.
      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) => {
      // Add to the cart with your own request, then tell the mini cart (see the `cart:add` 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();
  });
})();

Product Details

The buy box of a product page: brand, title, rating, sale price, color and size options, stock status, quantity, "Add to cart", wishlist and the product information.

Anatomy

  • .product-details: a form that posts the selected options and the quantity to the cart. data-variants holds the price and the stock of every size; data-currency the currency for the formatting.
  • .product-details__option: a fieldset of radio buttons, either color __swatches (the color is the --swatch custom property) or __size pills. The legend shows the selected value.
  • .product-details__stock: the stock status (--low, --out).
  • .product-details__buy: the quantity, the "Add to cart" and the wishlist buttons.
  • .product-details__info: native details elements for the longer information.

Accessibility and Usability

  • The options are native radio buttons in a fieldset with a legend: they are a group with arrow key navigation, and screen readers announce "Color: Forest green, radio button, 1 of 3". The swatches have the color name as hidden text.
  • A sold-out size is disabled and has hidden "(sold out)" text; visually it's struck through, so it's not just grayed out.
  • The rating says "Rated 4.6 out of 5" in text, and the sale price has "Sale price" and "Regular price" labels, because a strikethrough isn't announced.
  • The wishlist button is a toggle (aria-pressed); adding to the cart and the wishlist are announced in a polite live region.
  • Use an h1 for the title on a real product page; the demo uses h2 because the page already has one.

Technical Details

  • When the size changes, the script updates the price, the stock message, the maximum quantity and the button label ("Add to cart · $378" for two). The price stays correct on the server; this is only the display.
  • On submit, the script dispatches a bubbling cart:add event with the FormData instead of reloading the page: add the item with a request, then update the Mini Cart. Without JavaScript, the form posts normally.
  • The selected states use :has(:checked) and :has(:focus-visible), so the custom swatches keep the native keyboard behavior.

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.