Delivery Estimate

Delivery

Free 30-day returns

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

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

  &__title {
    align-items: center;
    display: flex;
    font-size: font-size('h6');
    gap: spacer('xs');

    svg {
      block-size: 1.25rem;
      color: color('primary');
      inline-size: 1.25rem;
    }
  }

  &__cutoff {
    background-color: color-alpha('success', 0.1, 'alert');
    border-radius: radius('md');
    color: color('heading');
    font-size: config('font-size-sm', $typography);
    padding: spacer('xs') spacer('s');

    strong {
      color: color('success', 'alert');
    }

    &[hidden] {
      display: none;
    }
  }

  &__form {
    @include layout-stack('xxs');
  }

  &__row {
    display: flex;
    gap: spacer('xs');

    .form-control {
      flex: 1 1 auto;
      min-inline-size: 0;
    }
  }

  &__error {
    color: color('invalid', 'form');
    font-size: config('font-size-sm', $typography);

    &[hidden] {
      display: none;
    }
  }

  &__options {
    font-size: config('font-size-sm', $typography);
    list-style: none;
    padding: 0;
    @include layout-stack('xs');

    &[hidden] {
      display: none;
    }

    li {
      align-items: baseline;
      display: flex;
      gap: spacer('s');
      justify-content: space-between;
    }

    li > span:first-child {
      color: color('muted');
    }

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

    li > span:last-child {
      color: color('heading');
      font-weight: 600;
      text-align: end;
    }
  }

  &__returns {
    border-block-start: config('border-width-base', $display) solid color('border');
    font-size: config('font-size-sm', $typography);
    padding-block-start: spacer('s');
  }
}
<section class="delivery-estimate" aria-labelledby="delivery-estimate-title" data-delivery-estimate data-cutoff="16">
    <h2 class="delivery-estimate__title" id="delivery-estimate-title">
        <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="M3 7h11v9H3z"/><path d="M14 10h4l3 3v3h-7z"/><circle cx="7" cy="17.5" r="1.75"/><circle cx="17" cy="17.5" r="1.75"/></svg>
        Delivery
    </h2>
    <p class="delivery-estimate__cutoff" data-delivery-cutoff hidden></p>
    <form class="delivery-estimate__form" action="#" method="get" novalidate data-delivery-form>
        <label class="form-label" for="delivery-zip">ZIP code</label>
        <div class="delivery-estimate__row">
            <input class="form-control form-control--sm" id="delivery-zip" name="zip" type="text" inputmode="numeric" autocomplete="postal-code" pattern="[0-9]{5}" maxlength="5" placeholder="97205" aria-describedby="delivery-zip-error">
            <button class="btn btn--secondary btn--sm" type="submit">Check</button>
        </div>
        <p class="delivery-estimate__error" id="delivery-zip-error" hidden data-delivery-error>Enter a 5-digit ZIP code.</p>
    </form>
    <div aria-live="polite" data-delivery-results-region>
        <ul class="delivery-estimate__options" role="list" hidden data-delivery-results>
            <li><span><strong>Standard</strong> Free over $50</span> <span data-delivery-days="5">—</span></li>
            <li><span><strong>Express</strong> $12.90</span> <span data-delivery-days="2">—</span></li>
            <li><span><strong>Pick up in store</strong> Free</span> <span data-delivery-days="0">—</span></li>
        </ul>
    </div>
    <p class="delivery-estimate__returns"><a href="#">Free 30-day returns</a></p>
</section>
(() => {
  const format = new Intl.DateTimeFormat(document.documentElement.lang || 'en', { weekday: 'long', month: 'short', day: 'numeric' });

  // Add business days (skipping the weekend) to a date.
  const addBusinessDays = (date, days) => {
    const result = new Date(date);
    let added = 0;

    while (added < days) {
      result.setDate(result.getDate() + 1);
      if (result.getDay() !== 0 && result.getDay() !== 6) added += 1;
    }

    return result;
  };

  document.querySelectorAll('[data-delivery-estimate]').forEach((section) => {
    const form = section.querySelector('[data-delivery-form]');
    const zip = form.elements.zip;
    const error = section.querySelector('[data-delivery-error]');
    const results = section.querySelector('[data-delivery-results]');
    const cutoff = section.querySelector('[data-delivery-cutoff]');
    const cutoffHour = Number(section.dataset.cutoff);

    // "Order within …" until the daily cut-off hour; after it, the orders ship a day later.
    const updateCutoff = () => {
      const now = new Date();
      const end = new Date(now);

      end.setHours(cutoffHour, 0, 0, 0);

      const minutes = Math.round((end - now) / 60000);

      cutoff.hidden = minutes <= 0 || now.getDay() === 0 || now.getDay() === 6;
      cutoff.innerHTML = `Order within <strong>${Math.floor(minutes / 60)} h ${minutes % 60} min</strong> to ship today.`;

      return minutes > 0;
    };

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

      const isValid = /^[0-9]{5}$/.test(zip.value.trim());

      error.hidden = isValid;
      zip.setAttribute('aria-invalid', String(!isValid));

      if (!isValid) {
        results.hidden = true;
        zip.focus();
        return;
      }

      const start = updateCutoff() ? new Date() : addBusinessDays(new Date(), 1);

      results.querySelectorAll('[data-delivery-days]').forEach((cell) => {
        const days = Number(cell.dataset.deliveryDays);

        cell.textContent = days === 0 ? 'Ready in 2 hours' : format.format(addBusinessDays(start, days));
      });
      results.hidden = false;
    });

    updateCutoff();
    setInterval(updateCutoff, 60000);
  });
})();

Delivery Estimate

A delivery box for the product page: an "order within" cut-off countdown, a ZIP code check and the estimated delivery date of each shipping option.

Anatomy

  • .delivery-estimate__cutoff: the time left to ship today, shown until the daily cut-off hour (data-cutoff, 16 here) on weekdays.
  • .delivery-estimate__form: the ZIP code field with an __error.
  • .delivery-estimate__options: the shipping options with their estimated dates, in a polite live region.

Accessibility and Usability

  • The results are announced after the check, because they appear in a live region.
  • The ZIP field opens the numeric keyboard (inputmode="numeric"), autofills (autocomplete="postal-code") and explains the format in the error; the error is linked with aria-describedby, and the field gets aria-invalid.
  • The dates are written out with the weekday ("Thursday, Oct 8"), which is easier to plan with than "in 3 days".
  • The countdown is a static text that updates once a minute, not a live region: announcing every minute would be noise.

Technical Details

  • The script adds business days (skipping weekends) to today, or to the next business day after the cut-off. Use your carrier's API for real estimates, holidays included.
  • The dates use Intl.DateTimeFormat with the page language.
  • Without JavaScript, the form submits the ZIP code (GET), and the server can render the estimates.

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.