Free Shipping Progress

You’re $11.00 away from free shipping.

$89 of $100

Steel Water Bottle $24.00

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

.free-shipping {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  padding: spacer('m');
  @include layout-stack('s');

  &__message {
    align-items: center;
    color: color('text');
    display: flex;
    gap: spacer('xs');
    margin: 0;

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

  &__icon {
    block-size: 1.5rem;
    color: color('primary');
    flex-shrink: 0;
    inline-size: 1.5rem;
  }

  &__bar {
    block-size: 0.5rem;
  }

  &--unlocked &__icon {
    color: color('success', 'alert');
  }

  &--unlocked &__bar {
    accent-color: color('success', 'alert');

    &::-webkit-progress-value {
      background-color: color('success', 'alert');
    }

    &::-moz-progress-bar {
      background-color: color('success', 'alert');
    }
  }

  &__suggestion {
    align-items: center;
    background-color: color('surface-muted');
    border-radius: radius('md');
    display: grid;
    gap: spacer('s');
    grid-template-columns: 3rem minmax(0, 1fr) auto;
    padding: spacer('xs');

    img {
      aspect-ratio: 1;
      border-radius: radius('sm');
      inline-size: 3rem;
      object-fit: cover;
    }
  }

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

  &__product-name {
    color: color('heading');
    font-weight: 600;
  }

  &__product-price {
    color: color('muted');
    font-size: config('font-size-sm', $typography);
  }
}
<section class="free-shipping" aria-label="Free shipping" data-free-shipping data-threshold="100" data-subtotal="89">
    <p class="free-shipping__message" aria-live="polite" data-free-shipping-message>
        <svg class="free-shipping__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 data-free-shipping-text>You’re <strong>$11.00</strong> away from free shipping.</span>
    </p>
    <progress class="free-shipping__bar" max="100" value="89" aria-label="Progress toward free shipping" data-free-shipping-bar>$89 of $100</progress>
    <div class="free-shipping__suggestion" data-free-shipping-suggestion>
        <img src="https://images.unsplash.com/photo-1602143407151-7111542de6e8?auto=format&fit=crop&w=96&h=96&q=60" alt="" width="48" height="48" loading="lazy">
        <p class="free-shipping__product">
            <span class="free-shipping__product-name">Steel Water Bottle</span>
            <span class="free-shipping__product-price">$24.00</span>
        </p>
        <form action="#" method="post" data-free-shipping-add>
            <input type="hidden" name="product" value="steel-water-bottle">
            <button class="btn btn--outline-primary btn--sm" type="submit" data-price="24">Add<span class="sr-only"> Steel Water Bottle</span></button>
        </form>
    </div>
</section>
(() => {
  document.querySelectorAll('[data-free-shipping]').forEach((component) => {
    const format = new Intl.NumberFormat(document.documentElement.lang || 'en', { style: 'currency', currency: 'USD' });
    const threshold = Number(component.dataset.threshold);
    const bar = component.querySelector('[data-free-shipping-bar]');
    const text = component.querySelector('[data-free-shipping-text]');
    const form = component.querySelector('[data-free-shipping-add]');

    const update = () => {
      const subtotal = Number(component.dataset.subtotal);
      const missing = threshold - subtotal;
      const unlocked = missing <= 0;

      bar.max = threshold;
      bar.value = Math.min(subtotal, threshold);
      bar.textContent = `${format.format(subtotal)} of ${format.format(threshold)}`;
      component.classList.toggle('free-shipping--unlocked', unlocked);
      text.innerHTML = unlocked
        ? 'You’ve unlocked <strong>free shipping</strong>!'
        : `You’re <strong>${format.format(missing)}</strong> away from free shipping.`;
    };

    // Call this when the cart changes, for example from the `cart:add` event of the product forms.
    component.addEventListener('cart:update', (event) => {
      component.dataset.subtotal = event.detail.subtotal;
      update();
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault();
      const button = form.querySelector('button');

      component.dispatchEvent(new CustomEvent('cart:update', {
        detail: { subtotal: Number(component.dataset.subtotal) + Number(button.dataset.price) }
      }));
      button.disabled = true;
      button.firstChild.textContent = 'Added';
    });

    update();
  });
})();

Free Shipping Progress

A progress bar toward the free shipping threshold for the cart, with the missing amount and a product suggestion that fills the gap.

Anatomy

  • .free-shipping: the wrapper with data-threshold and data-subtotal; it gets --unlocked once the threshold is reached.
  • .free-shipping__message: the icon and the text with the missing amount.
  • .free-shipping__bar: a native progress element.
  • .free-shipping__suggestion: an optional add-on product with its own "Add" form.

Accessibility and Usability

  • The message is a polite live region: when the cart changes, the new missing amount (or "You’ve unlocked free shipping!") is announced.
  • The progress element has a label ("Progress toward free shipping"), and its fallback text is the amount.
  • The "Add" button has hidden context ("Add Steel Water Bottle"); after adding, it's disabled and says "Added".
  • Say the exact amount ("$11.00 away"); it's more motivating and clearer than a bar alone.

Technical Details

  • Update the component with a cart:update event on it, with the new subtotal in detail ({ subtotal: 113 }), for example after your add-to-cart request. The demo dispatches it from the suggestion's form.
  • Without JavaScript, the message and the bar are rendered by the server, and the "Add" form posts normally.
  • Use it in the Mini Cart, the Cart Summary or a banner on the product page.

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.