Tutorial
How to build a free shipping progress bar in Shopify (no app)
A complete guide to building a "You're $12.00 away from free shipping" cart bar in Shopify with Liquid — the code, the cents-vs-dollars trap, live cart updates, styling, and how to make the threshold a merchant setting. No app required.
Bas Lefeber
Founder, learnshopify.dev · May 21, 2026 · 5 min read
A free shipping progress bar is the little cart message that says You're $12.00 away from free shipping and fills up as the shopper adds items. It's one of the most reliable ways to lift average order value on a Shopify store, and you can build it yourself in about ten lines of Liquid (no app, no monthly fee).
This guide covers the whole thing: the core code, the one bug that breaks most copy-pasted versions, how to style it, how to make it update live as the cart changes, where to place it, and how to hand the threshold to the merchant so they can change it without touching code.
Rather build it than read about it?
There's an interactive version of this where you write the Liquid yourself into a pre-styled cart and watch the bar fill as you type. Build the free shipping bar in the editor →
What you'll need
- Access to your theme code (
Online Store → Themes → Edit code). - A cart template or cart drawer to drop the markup into.
- Basic comfort with Liquid — if you're new to it, the lesson linked near the end walks the core idea step by step.
The free shipping bar code
Here's the complete snippet. Drop it into your cart template (in Shopify's Horizon theme, that's the cart section). We'll break down every line next.
{% assign threshold = 5000 %}{% assign remaining = threshold | minus: cart.total_price %} <div class="free-shipping-bar"> {% if remaining > 0 %} <p>You're {{ remaining | money }} away from free shipping</p> {% else %} <p>You've unlocked free shipping</p> {% endif %} <div class="free-shipping-bar__track"> <span style="width: {{ cart.total_price | times: 100.0 | divided_by: threshold }}%"></span> </div></div>How it works, line by line
The whole feature hinges on one fact about Shopify: cart.total_price is in cents, not dollars. A $50.00 cart is 5000. So:
threshold = 5000is your free-shipping cutoff, $50.00 expressed in cents.remaining = threshold | minus: cart.total_priceis how many cents are left to qualify.- The
moneyfilter turns those cents back into a properly formatted$12.00in the store's own currency — never hardcode a$. - The bar width is the cart total as a percentage of the threshold:
cart.total_price | times: 100.0 | divided_by: threshold. Multiplying by100.0(a float) forces decimal division so you get a smooth percentage instead of integer rounding.
The trap that breaks most versions
If you write if cart.total_price > 50, you're comparing 5000 cents against 50 — so the bar "completes" at fifty cents. This is the single most common bug in copy-pasted free-shipping bars. Always work in cents, and only convert to a display string at the very end with money.
Styling the bar
The markup is intentionally plain so it inherits your theme. Here's a minimal, themeable starting point — adjust the colors to your brand.
.free-shipping-bar__track { height: 6px; border-radius: 999px; background: rgba(0, 0, 0, 0.08); overflow: hidden;}.free-shipping-bar__track span { display: block; height: 100%; background: #1a7f5a; transition: width 240ms ease;}Tip
The transition: width is what makes the bar glide when the cart total changes instead of snapping. Small detail, big perceived-quality difference.
Making it update live as the cart changes
Out of the box the bar is correct on every full page load, but in a cart drawer the total changes without a reload — so the bar can go stale. The fix is to re-fetch the cart and re-render the bar whenever the cart updates. Most modern themes dispatch an event you can listen for; the universal fallback is to read /cart.js after any add/update.
const THRESHOLD = 5000; // keep in sync with the Liquid threshold async function refreshShippingBar() { const res = await fetch('/cart.js'); const cart = await res.json(); const remaining = THRESHOLD - cart.total_price; const bar = document.querySelector('.free-shipping-bar'); if (!bar) return; bar.querySelector('p').textContent = remaining > 0 ? `You're ${(remaining / 100).toFixed(2)} away from free shipping` : "You've unlocked free shipping"; const pct = Math.min(100, (cart.total_price / THRESHOLD) * 100); bar.querySelector('.free-shipping-bar__track span').style.width = pct + '%';} // Re-run whenever the cart changes (drawer add/remove/update).document.addEventListener('cart:updated', refreshShippingBar);Note
The event name varies by theme — Horizon and many Online Store 2.0 themes publish a cart-update event, while older themes may use a different name or a global pub/sub. Check your theme's cart JavaScript for the exact event, or call refreshShippingBar() yourself right after your add-to-cart fetch resolves.
Where to put the bar
- Cart drawer / slide cart — the highest-converting spot. The shopper sees it the moment they add something.
- Cart page — always worth having for shoppers who navigate straight to
/cart. - Announcement bar — a site-wide "Free shipping over $50" works as a softer, ever-present nudge, though it can't show live progress without the JS above.
Make the threshold a merchant setting
Hardcoding 5000 works, but the merchant can't change their free-shipping threshold without editing code — and they will want to, usually right before a sale. Move it to a section setting so it's editable from the theme editor.
{ "type": "number", "id": "free_shipping_threshold", "label": "Free shipping threshold (in cents)", "default": 5000}Then read it at the top of the section with {% assign threshold = section.settings.free_shipping_threshold %}. Same code, but now the threshold is the merchant's setting to own — which is the difference between a snippet and a feature.
What AI tools get wrong here
Ask Copilot or Shopify Magic for this and you'll usually get one of two mistakes. Spotting them on sight is exactly the judgment that stays valuable when AI writes the first draft:
- Cents vs. dollars — comparing the raw
cart.total_priceagainst a dollar number, so the math is off by 100×. - Hardcoded currency — writing
${{ remaining }}instead of{{ remaining | money }}, which silently breaks every non-USD store.
Learn this properly · free lesson
Build a free-shipping progress bar
Build this exact bar step by step in our interactive editor, against a live store, with a reviewer checking your work. It's a free lesson — no signup wall.
Try this lesson — freeWrapping up
Ten lines of Liquid, one cents-vs-dollars gotcha, a touch of JavaScript to keep it live, and one setting to hand control back to the merchant. That's the whole feature — and the gap between code that demos and code that ships.
Frequently asked questions
Does Shopify have a built-in free shipping bar?
No. Shopify lets you configure free shipping as a rate in Settings → Shipping, but there's no built-in progress bar in the cart. You either add one with the Liquid in this guide or install an app. The Liquid approach is free and gives you full control over the design.
Why is my free shipping bar not updating in the cart drawer?
Because the cart total changes without a page reload in a drawer. Liquid only runs on full page loads, so you need the small JavaScript snippet above that listens for your theme's cart-update event, re-fetches /cart.js, and re-renders the bar.
Should the threshold be in cents or dollars?
Cents. cart.total_price is always in the store's smallest currency unit (cents for USD), so your threshold must match. A $50 threshold is 5000. Only convert to a display string at the end with the money filter.
Will this work on a multi-currency store?
Yes, as long as you format every amount with the money (or money_with_currency) filter instead of hardcoding a symbol. The filter renders the amount in whatever currency the customer is shopping in. Note that a fixed cents threshold is the same number across currencies, so consider whether your free-shipping rules differ per market.
Do I need an app for a free shipping bar?
No. This is a no-app solution — pure Liquid plus a few lines of optional JavaScript for live updates. Apps are worth it only if you want non-developers to configure complex rules without touching code.
Keep going in the curriculum