Coupon Form

Discount code

Try SPRUCE10 or FREESHIP.

    Subtotal
    $128.00
    Shipping
    $6.90
    Total
    $134.90
    @use 'sprucecss/scss/spruce' as *;
    
    .coupon-form {
      background-color: color('surface');
      border: config('border-width-base', $display) solid color('border');
      border-radius: radius('lg');
      padding: spacer('m');
      @include layout-stack('s');
    
      &__title {
        font-size: font-size('h5');
      }
    
      &__form {
        display: flex;
        gap: spacer('xs');
    
        .form-control {
          flex: 1 1 auto;
          min-inline-size: 0;
          text-transform: uppercase;
    
          &::placeholder {
            text-transform: none;
          }
        }
      }
    
      &__message {
        color: color('muted');
        font-size: config('font-size-sm', $typography);
    
        &--error {
          color: color('invalid', 'form');
        }
    
        &--success {
          color: color('success', 'alert');
        }
      }
    
      &__applied {
        display: flex;
        flex-wrap: wrap;
        gap: spacer('xs');
        list-style: none;
        padding: 0;
    
        &:empty {
          display: none;
        }
    
        // The items sit side by side: clear the stack margins of the base list style.
        > li {
          margin-block: 0;
          padding-block: 0;
        }
      }
    
      &__chip {
        align-items: center;
        background-color: color-alpha('success', 0.12, 'alert');
        border-radius: radius('pill');
        color: color('heading');
        display: inline-flex;
        font-size: config('font-size-sm', $typography);
        gap: spacer('xxs');
        margin: 0 !important;
        padding-block: spacer('xxs');
        padding-inline: spacer('s') spacer('xxs');
    
        > svg {
          block-size: 1rem;
          color: color('success', 'alert');
          inline-size: 1rem;
        }
      }
    
      &__code {
        font-weight: 700;
        letter-spacing: 0.04em;
      }
    
      &__detail {
        color: color('muted');
      }
    
      &__remove {
        @include clear-btn;
        align-items: center;
        block-size: 1.5rem;
        border-radius: radius('pill');
        color: color('muted');
        display: inline-flex;
        inline-size: 1.5rem;
        justify-content: center;
    
        &:hover {
          background-color: color-alpha('danger', 0.12, 'alert');
          color: color('danger', 'alert');
        }
    
        &:focus-visible {
          outline: 2px solid color('focus-ring');
          outline-offset: 1px;
        }
    
        svg {
          block-size: 0.9rem;
          inline-size: 0.9rem;
        }
      }
    
      &__totals {
        border-block-start: config('border-width-base', $display) solid color('border');
        margin-block: spacer('s') 0;
        padding-block-start: spacer('s');
        @include layout-stack('xxs');
    
        div {
          display: flex;
          justify-content: space-between;
        }
    
        dt {
          font-weight: 400;
        }
    
        dd {
          font-variant-numeric: tabular-nums;
          margin: 0;
        }
    
        [hidden] {
          display: none;
        }
      }
    
      &__discount dd {
        color: color('success', 'alert');
        font-weight: 600;
      }
    
      &__total {
        color: color('heading');
        font-size: config('font-size-lg', $typography);
        font-weight: 700;
    
        dt {
          font-weight: 700;
        }
      }
    }
    <section class="coupon-form" aria-labelledby="coupon-form-title" data-coupon-form data-subtotal="128.00">
        <h2 class="coupon-form__title" id="coupon-form-title">Discount code</h2>
        <form class="coupon-form__form" action="#" method="post" novalidate data-coupon-form-form>
            <label class="sr-only" for="coupon-code">Discount code</label>
            <input class="form-control" id="coupon-code" name="code" type="text" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="Enter a code" aria-describedby="coupon-form-message">
            <button class="btn btn--secondary" type="submit">Apply</button>
        </form>
        <p class="coupon-form__message" id="coupon-form-message" aria-live="polite" data-coupon-form-message>Try <strong>SPRUCE10</strong> or <strong>FREESHIP</strong>.</p>
        <ul class="coupon-form__applied" role="list" data-coupon-form-applied></ul>
        <dl class="coupon-form__totals">
            <div>
                <dt>Subtotal</dt>
                <dd>$128.00</dd>
            </div>
            <div class="coupon-form__discount" hidden data-coupon-form-discount-row>
                <dt>Discount</dt>
                <dd data-coupon-form-discount>−$0.00</dd>
            </div>
            <div>
                <dt>Shipping</dt>
                <dd data-coupon-form-shipping>$6.90</dd>
            </div>
            <div class="coupon-form__total">
                <dt>Total</dt>
                <dd data-coupon-form-total>$134.90</dd>
            </div>
        </dl>
        <template data-coupon-form-chip>
            <li class="coupon-form__chip">
                <svg 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="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8z"/><path d="M7.5 7.5h.01"/></svg>
                <span class="coupon-form__code"></span>
                <span class="coupon-form__detail"></span>
                <button class="coupon-form__remove" type="button"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg><span class="sr-only"></span></button>
            </li>
        </template>
    </section>
    (() => {
      // Demo codes; a real shop validates the code on the server.
      const codes = {
        SPRUCE10: { type: 'percent', value: 10, detail: '10% off' },
        FREESHIP: { type: 'shipping', detail: 'Free shipping' }
      };
      const shippingCost = 6.9;
      const money = (value) => `$${value.toFixed(2)}`;
    
      document.querySelectorAll('[data-coupon-form]').forEach((section) => {
        const form = section.querySelector('[data-coupon-form-form]');
        const input = form.elements.code;
        const message = section.querySelector('[data-coupon-form-message]');
        const applied = section.querySelector('[data-coupon-form-applied]');
        const template = section.querySelector('[data-coupon-form-chip]');
        const subtotal = Number(section.dataset.subtotal);
        const active = new Set();
    
        const setMessage = (text, type) => {
          message.textContent = text;
          message.className = `coupon-form__message${type ? ` coupon-form__message--${type}` : ''}`;
          input.setAttribute('aria-invalid', String(type === 'error'));
        };
    
        const render = () => {
          let discount = 0;
          let shipping = shippingCost;
    
          applied.replaceChildren();
          active.forEach((code) => {
            const coupon = codes[code];
            const chip = template.content.firstElementChild.cloneNode(true);
    
            if (coupon.type === 'percent') discount += subtotal * coupon.value / 100;
            if (coupon.type === 'shipping') shipping = 0;
    
            chip.querySelector('.coupon-form__code').textContent = code;
            chip.querySelector('.coupon-form__detail').textContent = coupon.detail;
            chip.querySelector('.coupon-form__remove .sr-only').textContent = `Remove the code ${code}`;
            chip.querySelector('.coupon-form__remove').dataset.code = code;
            applied.append(chip);
          });
    
          section.querySelector('[data-coupon-form-discount-row]').hidden = discount === 0;
          section.querySelector('[data-coupon-form-discount]').textContent = `−${money(discount)}`;
          section.querySelector('[data-coupon-form-shipping]').textContent = shipping ? money(shipping) : 'Free';
          section.querySelector('[data-coupon-form-total]').textContent = money(subtotal - discount + shipping);
        };
    
        form.addEventListener('submit', (event) => {
          event.preventDefault();
    
          const code = input.value.trim().toUpperCase();
    
          if (!code) {
            setMessage('Enter a discount code.', 'error');
          } else if (!codes[code]) {
            setMessage(`The code ${code} isn’t valid. Check the spelling, or it may have expired.`, 'error');
          } else if (active.has(code)) {
            setMessage(`The code ${code} is already applied.`, 'error');
          } else {
            active.add(code);
            render();
            setMessage(`${code} applied: ${codes[code].detail.toLowerCase()}.`, 'success');
            form.reset();
            return;
          }
    
          input.focus();
        });
    
        applied.addEventListener('click', (event) => {
          const button = event.target.closest('.coupon-form__remove');
    
          if (!button) return;
    
          active.delete(button.dataset.code);
          render();
          setMessage(`The code ${button.dataset.code} is removed.`);
          input.focus();
        });
      });
    })();

    Coupon Form

    A discount code field for the cart or the checkout: apply a code, see it as a removable chip and watch the totals update.

    Anatomy

    • .coupon-form__form: the code field and the apply button.
    • .coupon-form__message: the hint, the error (--error) or the confirmation (--success).
    • .coupon-form__applied: the applied codes as __chips (code, what it does and a __remove button), cloned from a template.
    • .coupon-form__totals: a description list with the subtotal, the __discount, the shipping and the __total.

    Accessibility and Usability

    • The message is a polite live region linked to the field (aria-describedby), so the result of applying a code is announced; on an error the field gets aria-invalid and keeps the focus.
    • The errors are specific: an empty field, an invalid or expired code, and a code that's already applied.
    • The remove button has hidden text with the code ("Remove the code SPRUCE10"). After removing, the focus goes back to the field.
    • The field doesn't autocorrect or spell-check the code (autocomplete="off", spellcheck="false") and shows it in uppercase; the input is normalized to uppercase too.

    Technical Details

    • The codes in the script are only a demo: validate the code and calculate the totals on the server, and use the script only to show the result. Without JavaScript, the form posts the code.
    • The subtotal comes from data-subtotal; a percent code discounts it, and a shipping code makes the shipping free.
    • Use it in the Cart Summary or the Checkout.

    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.