Shipping Options

Shipping method
Subtotal
$173.00
Shipping
Free
Total
$173.00

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

.shipping-options {
  @include layout-stack('m');

  &__fieldset {
    border: 0;
    margin-inline: 0;
    padding: 0;
  }

  &__legend {
    color: color('heading');
    font-size: font-size('h5');
    font-weight: 700;
    margin-block-end: spacer('s');
    padding: 0;
  }

  &__list {
    display: grid;
    gap: spacer('xs');
  }

  &__option {
    align-items: center;
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    cursor: pointer;
    display: grid;
    gap: spacer('s');
    grid-template-columns: auto auto minmax(0, 1fr) auto;
    padding: spacer('s');
    @include transition($property: 'background-color, border-color, box-shadow');

    &:hover {
      border-color: color('border-strong');
    }

    &:has(:checked) {
      background-color: color-alpha('primary', 0.05);
      border-color: color('primary');
      box-shadow: 0 0 0 1px color('primary');
    }

    &:has(:disabled) {
      cursor: not-allowed;
      opacity: 0.6;
    }

    .form-check__control {
      margin: 0;
    }
  }

  &__icon {
    block-size: 1.5rem;
    color: color('muted');
    inline-size: 1.5rem;
  }

  &__option:has(:checked) &__icon {
    color: color('primary');
  }

  &__body {
    display: grid;
    line-height: config('line-height-md', $typography);
  }

  &__name {
    color: color('heading');
    font-weight: 700;
  }

  &__eta {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }

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

  &__summary {
    border-block-start: config('border-width-base', $display) solid color('border');
    padding-block-start: spacer('s');
    @include layout-stack('xs');

    div {
      display: flex;
      justify-content: space-between;
    }

    dt {
      font-weight: 400;
    }

    dd {
      color: color('heading');
      font-variant-numeric: tabular-nums;
      margin: 0;
    }
  }

  &__total {
    font-size: config('font-size-lg', $typography);

    dt,
    dd {
      color: color('heading');
      font-weight: 700;
    }
  }
}
<form class="shipping-options" action="#" method="post" data-shipping-options data-subtotal="173">
    <fieldset class="shipping-options__fieldset">
        <legend class="shipping-options__legend">Shipping method</legend>
        <div class="shipping-options__list">
            <label class="form-check shipping-options__option">
                <input class="form-check__control" type="radio" name="shipping" value="standard" data-price="0" aria-describedby="shipping-standard-eta" checked>
                <svg class="shipping-options__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="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2"/><path d="M15 18H9"/><path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62L18.3 9.38a1 1 0 0 0-.78-.38H14"/><circle cx="17" cy="18" r="2"/><circle cx="7" cy="18" r="2"/></svg>
                <span class="shipping-options__body">
                    <span class="shipping-options__name">Standard</span>
                    <span class="shipping-options__eta" id="shipping-standard-eta">3–5 business days, arrives <time datetime="2026-10-08">Oct 8</time>–<time datetime="2026-10-10">10</time></span>
                </span>
                <span class="shipping-options__price">Free</span>
            </label>
            <label class="form-check shipping-options__option">
                <input class="form-check__control" type="radio" name="shipping" value="express" data-price="12" aria-describedby="shipping-express-eta">
                <svg class="shipping-options__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="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
                <span class="shipping-options__body">
                    <span class="shipping-options__name">Express</span>
                    <span class="shipping-options__eta" id="shipping-express-eta">Order in the next 3 hours, arrives <time datetime="2026-10-05">tomorrow</time></span>
                </span>
                <span class="shipping-options__price">$12.00</span>
            </label>
            <label class="form-check shipping-options__option">
                <input class="form-check__control" type="radio" name="shipping" value="pickup" data-price="0" aria-describedby="shipping-pickup-eta">
                <svg class="shipping-options__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="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"/><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"/><path d="M2 7h20"/></svg>
                <span class="shipping-options__body">
                    <span class="shipping-options__name">Store pickup</span>
                    <span class="shipping-options__eta" id="shipping-pickup-eta">Free · Ready in 2 hours at Andrássy út 12, Budapest</span>
                </span>
                <span class="shipping-options__price">Free</span>
            </label>
            <label class="form-check shipping-options__option">
                <input class="form-check__control" type="radio" name="shipping" value="same-day" data-price="19" aria-describedby="shipping-same-day-eta" disabled>
                <svg class="shipping-options__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"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
                <span class="shipping-options__body">
                    <span class="shipping-options__name">Same-day delivery</span>
                    <span class="shipping-options__eta" id="shipping-same-day-eta">Not available for this address</span>
                </span>
                <span class="shipping-options__price">$19.00</span>
            </label>
        </div>
    </fieldset>
    <dl class="shipping-options__summary">
        <div><dt>Subtotal</dt><dd>$173.00</dd></div>
        <div><dt>Shipping</dt><dd data-shipping-cost>Free</dd></div>
        <div class="shipping-options__total"><dt>Total</dt><dd data-shipping-total>$173.00</dd></div>
    </dl>
    <p class="sr-only" aria-live="polite" data-shipping-live></p>
    <button class="btn btn--primary btn--block" type="submit">Continue to payment</button>
</form>
(() => {
  document.querySelectorAll('[data-shipping-options]').forEach((form) => {
    const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: 'USD' });
    const subtotal = Number(form.dataset.subtotal);
    const cost = form.querySelector('[data-shipping-cost]');
    const total = form.querySelector('[data-shipping-total]');
    const live = form.querySelector('[data-shipping-live]');

    form.addEventListener('change', (event) => {
      if (event.target.name !== 'shipping') {
        return;
      }

      const price = Number(event.target.dataset.price);

      cost.textContent = price ? format.format(price) : 'Free';
      total.textContent = format.format(subtotal + price);
      live.textContent = `Total: ${total.textContent}`;
    });
  });
})();

Shipping Options

The shipping step of a checkout: selectable delivery methods with the delivery estimate and the price, and an order total that follows the choice.

Anatomy

  • .shipping-options__option: a label (also a .form-check, which sets the radio's size variables) around a radio button, an icon, the __body (__name and the __eta) and the __price. The radio's data-price is the shipping cost.
  • .shipping-options__summary: the subtotal, the shipping cost and the __total; the form's data-subtotal is the base.

Accessibility and Usability

  • The methods are native radio buttons in a fieldset with a visible legend: arrow keys move between them, and the whole card is the click target.
  • The delivery estimate is connected with aria-describedby, so screen readers announce "Express, radio button, Order in the next 3 hours, arrives tomorrow".
  • An unavailable method stays visible and disabled with the reason ("Not available for this address"), so people don't wonder where it went.
  • The new total is announced in a polite live region when the choice changes.
  • Show concrete dates ("arrives Oct 8–10"), not only "3–5 business days".

Technical Details

  • The selected card is styled with :has(:checked), so no class has to be toggled.
  • The script only updates the displayed total; calculate the real shipping price and the total on the server. Without JavaScript, the radios still submit with the form.
  • Use it in the Checkout before the payment step.

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.