Subscribe and Save

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

.subscribe-save {
  @include layout-stack('m');

  &__options {
    border: 0;
    margin: 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;
  }

  &__option {
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');

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

  // The first option is the label itself; the subscription wraps a label and the details.
  > fieldset > label.subscribe-save__option,
  &__label {
    align-items: center;
    cursor: pointer;
    display: flex;
    gap: spacer('s');
    padding: spacer('s');
  }

  &__text {
    align-items: center;
    display: flex;
    flex: 1 1 auto;
    flex-wrap: wrap;
    gap: spacer('xxs') spacer('xs');

    strong {
      color: color('heading');
    }
  }

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

    del {
      color: color('muted');
      display: block;
      font-size: config('font-size-sm', $typography);
      font-weight: 400;
    }
  }

  &__details {
    border-block-start: config('border-width-base', $display) dashed color('border');
    display: grid;
    gap: spacer('xxs');
    margin-inline: spacer('s');
    padding-block: spacer('s');

    .form-control {
      max-inline-size: 16rem;
    }
  }

  // Hide the details while the one-time option is chosen (where :has() works).
  @supports selector(:has(*)) {
    &__option--subscribe:not(:has(> label > input:checked)) &__details {
      display: none;
    }
  }

  &__perks {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
    list-style: none;
    margin-block-start: spacer('xs');
    padding: 0;

    li {
      align-items: center;
      display: flex;
      gap: spacer('xxs');
      margin: 0;

      &::before {
        color: color('success', 'alert');
        content: '✓';
        font-weight: 700;
      }
    }
  }

  &__buy {
    align-items: end;
    display: grid;
    gap: spacer('s');
    grid-template-columns: 6rem minmax(0, 1fr);
  }

  &__quantity {
    display: grid;
    gap: spacer('xxs');
  }
}
<form class="subscribe-save" action="#" method="post" data-subscribe-save data-price="24" data-discount="15">
    <fieldset class="subscribe-save__options">
        <legend class="subscribe-save__legend">Purchase option</legend>
        <label class="form-check subscribe-save__option">
            <input class="form-check__control" type="radio" name="purchase" value="once">
            <span class="subscribe-save__text">
                <strong>One-time purchase</strong>
            </span>
            <span class="subscribe-save__price">$24.00</span>
        </label>
        <div class="subscribe-save__option subscribe-save__option--subscribe">
            <label class="form-check subscribe-save__label">
                <input class="form-check__control" type="radio" name="purchase" value="subscribe" checked>
                <span class="subscribe-save__text">
                    <strong>Subscribe & save 15%</strong>
                    <span class="badge badge--success">Best value</span>
                </span>
                <span class="subscribe-save__price"><span class="sr-only">Subscription price: </span>$20.40 <del><span class="sr-only">Regular price: </span>$24.00</del></span>
            </label>
            <div class="subscribe-save__details" data-subscribe-save-details>
                <label class="form-label" for="subscribe-frequency">Deliver every</label>
                <select class="form-control form-control--sm" id="subscribe-frequency" name="frequency">
                    <option value="2">2 weeks</option>
                    <option value="4" selected>4 weeks (most popular)</option>
                    <option value="6">6 weeks</option>
                    <option value="8">8 weeks</option>
                </select>
                <ul class="subscribe-save__perks" role="list">
                    <li>Free shipping on every delivery</li>
                    <li>Skip, pause or cancel anytime</li>
                </ul>
            </div>
        </div>
    </fieldset>
    <div class="subscribe-save__buy">
        <div class="subscribe-save__quantity">
            <label class="form-label" for="subscribe-quantity">Quantity</label>
            <select class="form-control" id="subscribe-quantity" name="quantity">
                <option>1</option>
                <option>2</option>
                <option>3</option>
            </select>
        </div>
        <button class="btn btn--primary btn--lg subscribe-save__submit" type="submit" data-subscribe-save-submit>Subscribe · $20.40 every 4 weeks</button>
    </div>
</form>
(() => {
  const money = (value) => `$${value.toFixed(2)}`;

  document.querySelectorAll('[data-subscribe-save]').forEach((form) => {
    const submit = form.querySelector('[data-subscribe-save-submit]');
    const price = Number(form.dataset.price);
    const discount = Number(form.dataset.discount);

    const update = () => {
      const { purchase, frequency, quantity } = form.elements;
      const isSubscription = purchase.value === 'subscribe';
      const total = price * Number(quantity.value) * (isSubscription ? 1 - discount / 100 : 1);

      // The button says exactly what happens: the price and, for a subscription, how often.
      submit.textContent = isSubscription
        ? `Subscribe · ${money(total)} every ${frequency.value} weeks`
        : `Add to cart · ${money(total)}`;
    };

    form.addEventListener('change', update);
    update();
  });
})();

Subscribe and Save

A purchase option picker for consumable products: buy once, or subscribe with a discount and choose the delivery frequency. The button shows the final price.

Anatomy

  • .subscribe-save__options: a fieldset with two __option cards. The one-time option is a label; the subscription option wraps a __label and the __details (the frequency select and the __perks).
  • .subscribe-save__buy: the quantity and the submit button.

Accessibility and Usability

  • The options are native radio buttons in a fieldset with a legend; the whole card is the label, so it's a large click target.
  • The discounted price has hidden "Subscription price" and "Regular price" labels, because a strikethrough isn't announced.
  • The submit button says the exact commitment ("Subscribe · $20.40 every 4 weeks"), so nobody subscribes by accident. The terms (skip, pause, cancel) are visible before the purchase.

Technical Details

  • The chosen card and the frequency visibility use :has(:checked); in browsers without :has(), the details stay visible.
  • The price and the discount come from data-price and data-discount; the script updates the button text when any option changes. Calculate the price on the server.
  • Without JavaScript, the form posts the chosen option, frequency and quantity.

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.