DEV Community

Cover image for Build a free shipping progress bar for Shopify with Liquid and JavaScript
Brix
Brix

Posted on Originally published at thebrix.io

Build a free shipping progress bar for Shopify with Liquid and JavaScript

Disclosure: I work on Brix, a Shopify cart app. This post shows how to build the feature yourself, and Brix is mentioned only at the end.

A free shipping progress bar is one of the simplest cart features to add, and it works because it makes a goal visible: "You're $12 away from free shipping." In this post we will build one with a Liquid snippet, a little CSS and a short script that keeps it in step with the cart.

It is written for a standard Online Store 2.0 theme such as Dawn. Themes differ, so treat the code as a starting point and test it in your own theme.

What we are building

  • A bar that fills as the cart total approaches a threshold.
  • A message that says how much more to spend, then switches to a success message.
  • Correct on first load (rendered by Liquid), and updated live when items are added, changed or removed.
  • Accessible: it exposes progress to screen readers and announces changes politely.

1. The Liquid snippet

Create snippets/free-shipping-bar.liquid. It takes a threshold in your store currency (for example 75) and renders the first state on the server, so there is no flash of an empty bar.

{%- liquid
  assign threshold_cents = threshold | times: 100
  assign total = cart.total_price
  assign remaining = threshold_cents | minus: total
  if remaining < 0
    assign remaining = 0
  endif
  assign percent = total | times: 100.0 | divided_by: threshold_cents | round
  if percent > 100
    assign percent = 100
  endif
-%}

<div class="fsb" data-fsb data-threshold="{{ threshold_cents }}">
  <p class="fsb__message" data-fsb-message aria-live="polite">
    {%- if remaining == 0 -%}
      You've unlocked free shipping.
    {%- else -%}
      You're {{ remaining | money }} away from free shipping.
    {%- endif -%}
  </p>
  <div
    class="fsb__track"
    role="progressbar"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuenow="{{ percent }}"
    aria-label="Progress toward free shipping"
  >
    <div class="fsb__fill" data-fsb-fill style="width: {{ percent }}%"></div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Render it wherever the cart total is visible, for example in your cart page section or cart drawer:

{% render 'free-shipping-bar', threshold: 75 %}
Enter fullscreen mode Exit fullscreen mode

2. The CSS

Add this to your theme's stylesheet (or a new asset). The width transition gives a smooth fill.

.fsb { margin: 0 0 1rem; }
.fsb__message { margin: 0 0 .5rem; font-size: .9rem; }
.fsb__track {
  height: 8px;
  background: #e5e7eb;
  border-radius: 999px;
  overflow: hidden;
}
.fsb__fill {
  height: 100%;
  background: #16a34a;
  border-radius: 999px;
  transition: width .3s ease;
}
@media (prefers-reduced-motion: reduce) {
  .fsb__fill { transition: none; }
}
Enter fullscreen mode Exit fullscreen mode

3. The JavaScript

Create assets/free-shipping-bar.js and load it with defer. It does two jobs: update the bar from /cart.js, and run that update after any cart-changing request.

(() => {
  const bar = document.querySelector('[data-fsb]');
  if (!bar) return;

  const threshold = Number(bar.dataset.threshold); // in cents
  const fill = bar.querySelector('[data-fsb-fill]');
  const message = bar.querySelector('[data-fsb-message]');
  const track = bar.querySelector('[role="progressbar"]');

  const currency =
    (window.Shopify && Shopify.currency && Shopify.currency.active) || 'USD';
  const money = new Intl.NumberFormat(document.documentElement.lang || undefined, {
    style: 'currency',
    currency,
  });

  function render(totalCents) {
    const remaining = Math.max(threshold - totalCents, 0);
    const percent = Math.min(Math.round((totalCents / threshold) * 100), 100);

    fill.style.width = percent + '%';
    track.setAttribute('aria-valuenow', percent);
    message.textContent =
      remaining === 0
        ? "You've unlocked free shipping."
        : `You're ${money.format(remaining / 100)} away from free shipping.`;
  }

  async function refresh() {
    const res = await fetch('/cart.js', { headers: { Accept: 'application/json' } });
    const cart = await res.json();
    render(cart.total_price);
  }

  // Run after any request that changes the cart.
  const originalFetch = window.fetch;
  window.fetch = function (...args) {
    const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
    const request = originalFetch.apply(this, args);
    if (/\/cart\/(add|change|update|clear)/.test(url)) {
      request.then(refresh).catch(() => {});
    }
    return request;
  };
})();
Enter fullscreen mode Exit fullscreen mode

Load it in layout/theme.liquid, before </body>:

<script src="{{ 'free-shipping-bar.js' | asset_url }}" defer></script>
Enter fullscreen mode Exit fullscreen mode

How it works

  • First paint: Liquid works out the remaining amount and the percentage, so the bar is right before any JavaScript runs.
  • Updates: wrapping fetch lets the bar refresh after the cart's add, change, update and clear requests, which is what most modern themes use. The script then asks /cart.js for the current total and redraws.
  • Accessibility: role="progressbar" with aria-valuenow exposes the progress, and aria-live="polite" announces the new message without interrupting.
  • Motion: the fill animates unless the visitor prefers reduced motion.

Limits and things to check

  1. Themes that use XHR instead of fetch. Older themes may not be covered by the wrapper. If so, listen for your theme's own cart event instead, or call refresh() from the theme's cart update function.
  2. Multiple currencies. The threshold above is in your store currency. If you sell in other currencies, you need to convert it (Shopify exposes a rate on Shopify.currency.rate in many themes), then test it in each currency.
  3. Currencies without decimals. The code assumes the usual 100 minor units. Check currencies such as JPY in your own store.
  4. Shipping rules live elsewhere. The bar only displays a goal. The actual free shipping rule must match it in your shipping settings.
  5. Choose the threshold from data. A threshold slightly above your current average order value tends to feel reachable. I wrote about how to pick the number here: how to set the right free shipping threshold.

Test it

On a development store with Dawn:

  1. Add the snippet, CSS and script, and render the snippet in the cart section.
  2. Add a product under the threshold. The bar should be partly filled and the message should show the amount left.
  3. Add more until you pass the threshold. The bar should fill and switch to the success message.
  4. Remove an item. The bar should shrink again.
  5. Test with a screen reader and with reduced motion turned on.

If you would rather not maintain this

Everything above works, but themes change, cart drawers differ and multi-currency adds edge cases. If you would rather not maintain it, Brix is the Shopify cart app I work on. It includes a multi-tier rewards progress bar, plus upsells, coupons and bundles, without custom code.

Questions about the Liquid or the script are welcome in the comments.

Top comments (0)