Free code · No app required
A free shipping progress bar is a snippet and a section. This one reads cart.total_price — the same number Shopify’s own price-based shipping rates use — and updates the moment the cart changes, including inside a drawer it knows nothing about. The amount remaining is formatted from your store’s own money format, so a no-decimals or comma-decimal store gets it right. Drop it in the header, the cart page, or straight into your cart drawer. First paint is server-rendered, so there is no flash of the wrong number. Every file below passes Shopify’s own Theme Check.
Read the first row before anything else. A progress bar does not create free shipping — it reports on a shipping rate you have to set up yourself. Nearly every tutorial on this topic leaves that out, which is how stores end up promising free delivery and then charging for it at checkout.
| Handled here | Needs a different build |
|---|---|
| Reports progress toward a threshold, server-rendered on first paint | Creating the free shipping rate itself — that lives in Settings → Shipping and delivery, and step 1 walks you through it |
| Updates live in any theme or app that uses Shopify's cart endpoints | A cart drawer that rewrites its own totals without ever calling /cart/add, change, update or clear |
| Matches your store's money format exactly, including no-decimals and comma-decimal formats | Converting the threshold per currency — multi-currency stores need a threshold per market |
| Works in the header, the cart page, or inside a cart drawer, several at once | Excluding specific products, collections, weights or countries from counting toward the total |
| Measures the total after discounts, matching how Shopify evaluates the rate | Free shipping for some customers only — tags, B2B price lists, logged-in wholesale |
Every item on the right needs something the storefront cannot see on its own: a rate engine, a market, or a customer record. That is app and custom-work territory. If you would rather not build any of it, our own Xperts Volume Discount app handles tiered and threshold-based offers without code — and the bar above is still free either way.
Two files. The snippet is the bar; the section is the merchant-facing wrapper that carries the settings, the CSS and the JavaScript. Add both — the snippet alone renders nothing.
snippets/free-shipping-progress.liquidThe bar itself, as a snippet so it can go anywhere — including inside a cart drawer, where a section cannot reach. Carries a {% doc %} header so the parameters show up in your editor.
{% doc %}
Renders a free-shipping progress bar for the current cart.
Display only. It does not create a free-shipping rate — set that up in
Settings > Shipping and delivery first, and use the same threshold here.
@param {number} threshold - Free-shipping threshold in the currency's subunit, e.g. 5000 for 50.00.
@param {string} [basis] - 'total' measures cart.total_price after discounts (default). 'subtotal' measures cart.items_subtotal_price.
@param {string} [away_text] - Shown below the threshold. [amount] is replaced with the amount remaining.
@param {string} [unlocked_text] - Shown once the threshold is reached.
@param {string} [empty_text] - Shown when the cart is empty. Omit to show away_text instead.
@example
{% render 'free-shipping-progress',
threshold: 5000,
away_text: 'Spend [amount] more for free shipping',
unlocked_text: 'Free shipping unlocked'
%}
{% enddoc %}
{%- liquid
assign goal = threshold | default: 0 | at_least: 0
assign measure = basis | default: 'total'
if measure == 'subtotal'
assign spent = cart.items_subtotal_price
else
assign spent = cart.total_price
endif
assign remaining = goal | minus: spent | at_least: 0
assign percent = 0
if goal > 0
assign percent = spent | times: 100 | divided_by: goal
assign percent = percent | at_most: 100
endif
assign unlocked = false
if goal > 0 and remaining == 0
assign unlocked = true
endif
assign away_copy = away_text | default: 'You are [amount] away from free shipping'
assign unlocked_copy = unlocked_text | default: 'You have unlocked free shipping'
-%}
{%- if goal > 0 -%}
{%- capture remaining_money -%}{{ remaining | money | strip_html }}{%- endcapture -%}
<free-shipping-progress
class="fsb{% if unlocked %} fsb--unlocked{% endif %}"
data-threshold="{{ goal }}"
data-basis="{{ measure }}"
data-away-text="{{ away_copy | escape }}"
data-unlocked-text="{{ unlocked_copy | escape }}"
{% if empty_text != blank %}
data-empty-text="{{ empty_text | escape }}"
{% endif %}
data-money-sample="{{ 123456789 | money | strip_html | escape }}"
style="--fsb-percent: {{ percent }}%;"
>
<p class="fsb__message" data-fsb-message role="status" aria-live="polite">
{%- liquid
if cart.item_count == 0 and empty_text != blank
echo empty_text
elsif unlocked
echo unlocked_copy
else
echo away_copy | replace: '[amount]', remaining_money
endif
-%}
</p>
{%- comment -%}
The track is decorative: the sentence above is the live region and already
says the same thing in words, so announcing a percentage as well would read
the state out twice.
{%- endcomment -%}
<div class="fsb__track" aria-hidden="true">
<div class="fsb__fill"></div>
</div>
</free-shipping-progress>
{%- endif -%}
sections/free-shipping-bar.liquidThe merchant-facing wrapper: theme-editor settings, colours, and all of the styling and behaviour. Renders the snippet above, so the two together are the whole feature.
<div
class="fsb-section fsb-section--{{ section.settings.width }}"
style="--fsb-bg: {{ section.settings.background }}; --fsb-fg: {{ section.settings.text_color }}; --fsb-track: {{ section.settings.track_color }}; --fsb-fill: {{ section.settings.fill_color }}; --fsb-fill-done: {{ section.settings.unlocked_color }};"
>
{%- render 'free-shipping-progress',
threshold: section.settings.threshold,
basis: section.settings.basis,
away_text: section.settings.away_text,
unlocked_text: section.settings.unlocked_text,
empty_text: section.settings.empty_text
-%}
</div>
{% stylesheet %}
.fsb-section {
background: var(--fsb-bg, #f4f4f4);
color: var(--fsb-fg, #121212);
}
.fsb-section--full .fsb {
max-width: none;
}
.fsb {
max-width: 1200px;
margin-inline: auto;
padding: 0.7rem 1rem;
}
.fsb__message {
margin: 0 0 0.5rem;
font-size: 0.9rem;
line-height: 1.45;
text-align: center;
}
.fsb__track {
height: 6px;
background: var(--fsb-track, #e0e0e0);
border-radius: 999px;
overflow: hidden;
}
.fsb__fill {
width: var(--fsb-percent, 0%);
height: 100%;
background: var(--fsb-fill, #2f9e86);
border-radius: inherit;
transition: width 0.35s ease, background-color 0.35s ease;
}
.fsb--unlocked .fsb__fill {
background: var(--fsb-fill-done, #1f7a66);
}
@media (prefers-reduced-motion: reduce) {
.fsb__fill {
transition: none;
}
}
{% endstylesheet %}
{% javascript %}
// Derives the store's money format from one rendered sample instead of
// reimplementing Shopify's money_format placeholders. The sample is
// 1,234,567.89 in the shop's own format, which is enough to read off the
// prefix, the suffix, the group separator, the decimal separator and the
// number of decimal places — so a custom format such as "1.234.567,89 EUR"
// or a no-decimals format formats correctly without a lookup table.
function readMoneyFormat(sample) {
const fallback = { prefix: '', suffix: '', group: ',', decimal: '.', places: 2 };
if (!sample) return fallback;
const digits = /[0-9]/;
let first = -1;
let last = -1;
for (let i = 0; i < sample.length; i += 1) {
if (digits.test(sample[i])) {
if (first === -1) first = i;
last = i;
}
}
if (first === -1) return fallback;
const prefix = sample.slice(0, first);
const suffix = sample.slice(last + 1);
const body = sample.slice(first, last + 1);
const separators = [];
for (let i = 0; i < body.length; i += 1) {
if (!digits.test(body[i])) separators.push({ char: body[i], index: i });
}
if (separators.length === 0) {
return { prefix, suffix, group: '', decimal: '', places: 0 };
}
const lastSep = separators[separators.length - 1];
const trailing = body.length - lastSep.index - 1;
// A run of one or two digits after the final separator is a decimal part;
// three means it was just another thousands group.
if (trailing === 1 || trailing === 2) {
const groups = separators.slice(0, -1);
return {
prefix,
suffix,
group: groups.length ? groups[groups.length - 1].char : '',
decimal: lastSep.char,
places: trailing,
};
}
return { prefix, suffix, group: lastSep.char, decimal: '', places: 0 };
}
function formatMoney(cents, format) {
const value = Math.abs(Number(cents) || 0) / 100;
const rounded = value.toFixed(format.places);
const parts = rounded.split('.');
let whole = parts[0];
if (format.group) {
whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, format.group);
}
const fraction = format.places > 0 && parts[1] ? format.decimal + parts[1] : '';
return format.prefix + whole + fraction + format.suffix;
}
class FreeShippingProgress extends HTMLElement {
connectedCallback() {
this.messageEl = this.querySelector('[data-fsb-message]');
this.fillEl = this.querySelector('.fsb__fill');
this.threshold = Number(this.dataset.threshold) || 0;
this.format = readMoneyFormat(this.dataset.moneySample);
FreeShippingProgress.register(this);
FreeShippingProgress.installCartHooks();
}
disconnectedCallback() {
FreeShippingProgress.unregister(this);
}
render(cart) {
if (!this.threshold) return;
const spent =
this.dataset.basis === 'subtotal' ? cart.items_subtotal_price : cart.total_price;
const remaining = Math.max(this.threshold - spent, 0);
const percent = Math.min((spent / this.threshold) * 100, 100);
this.style.setProperty('--fsb-percent', percent + '%');
this.classList.toggle('fsb--unlocked', remaining === 0);
if (!this.messageEl) return;
if (cart.item_count === 0 && this.dataset.emptyText) {
this.messageEl.textContent = this.dataset.emptyText;
} else if (remaining === 0) {
this.messageEl.textContent = this.dataset.unlockedText;
} else {
this.messageEl.textContent = this.dataset.awayText.replace(
'[amount]',
formatMoney(remaining, this.format)
);
}
}
}
FreeShippingProgress.instances = new Set();
FreeShippingProgress.register = function register(instance) {
FreeShippingProgress.instances.add(instance);
};
FreeShippingProgress.unregister = function unregister(instance) {
FreeShippingProgress.instances.delete(instance);
};
FreeShippingProgress.refresh = async function refresh() {
if (!FreeShippingProgress.instances.size) return;
const root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
try {
const response = await fetch(root + 'cart.js', {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) return;
const cart = await response.json();
FreeShippingProgress.instances.forEach((instance) => instance.render(cart));
} catch (error) {
// Leave the server-rendered value in place rather than blanking the bar.
}
};
// Nothing in a theme reliably announces "the cart changed" — Dawn publishes an
// internal event, other themes dispatch their own, apps dispatch none. What is
// common to all of them is the network call: changing a cart means hitting
// /cart/add, /cart/change, /cart/update or /cart/clear. Watching for those
// responses is the one signal every theme and app shares.
FreeShippingProgress.installCartHooks = function installCartHooks() {
if (window.__freeShippingBarHooked) return;
window.__freeShippingBarHooked = true;
const CART_WRITE = /\/cart\/(add|change|update|clear)/;
const schedule = () => window.setTimeout(FreeShippingProgress.refresh, 60);
if (typeof window.fetch === 'function') {
const nativeFetch = window.fetch;
window.fetch = function patchedFetch(...args) {
const result = nativeFetch.apply(this, args);
try {
const input = args[0];
const url = typeof input === 'string' ? input : input && input.url;
if (url && CART_WRITE.test(String(url))) result.then(schedule, () => {});
} catch (error) {
// Never let the hook interfere with the theme's own request.
}
return result;
};
}
if (window.XMLHttpRequest) {
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
this.__fsbCartWrite = CART_WRITE.test(String(url));
if (this.__fsbCartWrite) this.addEventListener('load', schedule);
return nativeOpen.call(this, method, url, ...rest);
};
}
// Covers a form POST to /cart/add that reloaded the page, and a cart page
// restored from the back/forward cache with stale totals.
window.addEventListener('pageshow', (event) => {
if (event.persisted) FreeShippingProgress.refresh();
});
document.addEventListener('cart:refresh', FreeShippingProgress.refresh);
};
if (!customElements.get('free-shipping-progress')) {
customElements.define('free-shipping-progress', FreeShippingProgress);
}
{% endjavascript %}
{% schema %}
{
"name": "Free shipping bar",
"tag": "section",
"class": "free-shipping-bar-section",
"settings": [
{
"type": "paragraph",
"content": "This bar only shows progress. Create the actual free-shipping rate in Settings > Shipping and delivery, then enter the same amount below."
},
{
"type": "text",
"id": "threshold",
"label": "Free shipping threshold, in cents",
"default": "5000",
"info": "5000 means 50.00. Use your store's default currency — if you sell in several currencies the bar compares against the amount the shopper sees, so set a per-market threshold instead."
},
{
"type": "select",
"id": "basis",
"label": "Measure against",
"default": "total",
"options": [
{ "value": "total", "label": "Cart total after discounts" },
{ "value": "subtotal", "label": "Subtotal before cart discounts" }
],
"info": "Match whichever one your shipping rate uses. Shopify's price-based rates look at the total after discounts."
},
{
"type": "header",
"content": "Messages"
},
{
"type": "text",
"id": "away_text",
"label": "Below the threshold",
"default": "You are [amount] away from free shipping",
"info": "[amount] is replaced with how much is left."
},
{
"type": "text",
"id": "unlocked_text",
"label": "Threshold reached",
"default": "You have unlocked free shipping"
},
{
"type": "text",
"id": "empty_text",
"label": "Empty cart",
"default": "Free shipping on orders over the threshold",
"info": "Leave blank to show the message above instead."
},
{
"type": "header",
"content": "Appearance"
},
{
"type": "select",
"id": "width",
"label": "Width",
"default": "contained",
"options": [
{ "value": "contained", "label": "Match page width" },
{ "value": "full", "label": "Full width" }
]
},
{
"type": "color",
"id": "background",
"label": "Background",
"default": "#f4f4f4"
},
{
"type": "color",
"id": "text_color",
"label": "Text",
"default": "#121212"
},
{
"type": "color",
"id": "track_color",
"label": "Progress track",
"default": "#e0e0e0"
},
{
"type": "color",
"id": "fill_color",
"label": "Progress fill",
"default": "#2f9e86"
},
{
"type": "color",
"id": "unlocked_color",
"label": "Progress fill when unlocked",
"default": "#1f7a66"
}
],
"presets": [
{
"name": "Free shipping bar"
}
]
}
{% endschema %}
Every file above, already in the right theme folder, plus a README with the install steps. Unzip it straight into your theme instead of copying 2 blocks by hand.
One download with every file in its correct theme folder. We'll also email you when the next free guide ships — one click to unsubscribe, and we never pass your address on. The code above stays free whether you enter an email or not.
Settings → Shipping and delivery → the shipping profile you use → Add rate. Name it, set the price to 0, then Add conditions → Based on order price, and put your threshold in the minimum field. Save. Do this before touching the theme: until this rate exists the bar is a promise your checkout does not keep, which is worse for trust than having no bar at all. Note the number you used — you will enter the same one in step 4.
Online Store → Themes → duplicate your live theme → Edit code. Under Snippets click Add a new snippet, name it free-shipping-progress, and paste the first file above. Save. On its own it renders nothing, because it needs a threshold passed in.
Under Sections click Add a new section, name it free-shipping-bar, and paste the second file. Save. This is the part the merchant sees in the theme editor, and it is what carries the CSS and JavaScript for both files.
In Customize, click Add section and choose Free shipping bar — at the top of the home page, inside the cart template, or in a header group so it shows everywhere. Then set the threshold in cents: 5000 means 50.00. To put the bar inside a cart drawer instead, where it does the most work, skip the section and render the snippet directly in your drawer markup with the line below.
{% render 'free-shipping-progress',
threshold: 5000,
basis: 'total',
away_text: 'You are [amount] away from free shipping',
unlocked_text: 'You have unlocked free shipping'
%}Add items until the bar flips to unlocked, then apply a discount code that drops the total back below the threshold. The bar should go back to counting, and the shipping step at checkout should agree with it. This is the test that catches a mismatched Measure against setting, which is the most common reason a bar and a checkout disagree. Also add and remove an item from your cart drawer without reloading — the bar should move both times.
No, and this is the single most common mistake with free shipping bars, paid apps included. The bar is a display. The real free shipping comes from a rate you create in Settings, Shipping and delivery: add a rate, set the price to zero, and add a condition based on order price with your threshold as the minimum. Until that rate exists the bar will cheerfully tell a customer they have unlocked free shipping and then checkout will charge them for delivery, which is worse than having no bar at all. That is why creating the rate is step one of the install rather than a footnote. Enter the same number in both places. If you later change the threshold in Shopify's shipping settings, change it in the theme editor too — nothing links them, and there is no warning when they drift apart.
After discounts, in almost every case, because that is what Shopify's own price-based shipping rates look at. If a customer has a 20% code applied, Shopify evaluates the free shipping condition against the discounted total — so a bar measuring the pre-discount subtotal will announce free shipping while the checkout disagrees. The setting defaults to Cart total after discounts for that reason, and it maps to cart.total_price. The other option, Subtotal before cart discounts, maps to cart.items_subtotal_price, which includes line item discounts but not cart-level ones; choose it only if you have deliberately configured your shipping rate the same way. Neither option includes taxes or shipping charges, which matches how Shopify evaluates the rate. If you are unsure, put a discount code on a test cart and compare what the bar says against what checkout charges.
It watches the network rather than the theme. There is no standard event for the cart changing in Shopify themes: Dawn publishes an internal message that outside code cannot subscribe to, other themes dispatch their own names, and most apps dispatch nothing at all. What every one of them has in common is the HTTP request, because changing a cart means calling /cart/add, /cart/change, /cart/update or /cart/clear. The bar wraps fetch and XMLHttpRequest, notices a response from one of those four paths, and re-reads /cart.js. That is why it updates inside a cart drawer it knows nothing about, and after an app quietly adds a free gift. The wrapper always calls the original function and swallows nothing, so it cannot break the request it is observing. The case it misses is a drawer that rewrites its own totals without ever calling those endpoints, which is rare.
The threshold does not convert, and you should know that before installing it. You enter one number in the theme editor, in subunits of your store's default currency. The cart total it is compared against arrives in the shopper's presentment currency. For a single-currency store those are the same thing and everything is correct. For a store selling in several they are not: a threshold of 5000 meaning fifty dollars gets compared against a cart priced in euros or rupees, and the bar is wrong by whatever the exchange rate is. Shopify has no Liquid filter that converts between the two. The fix is a threshold per market, which means reading the active market in Liquid and choosing a value for it — a small piece of custom work, and the point where the free version stops. The money formatting itself is always correct, because it is derived from your own rendered format.
All three work, and it depends on where your shoppers actually decide. The strongest single placement is inside the cart drawer, immediately above the checkout button, because that is the moment a shopper is weighing one more item. The code ships as a snippet precisely so it can be rendered there and not only as a section. A header placement reaches every page and suits stores where free shipping is a core promise, but it is also the easiest thing for a shopper to stop noticing after the second page. The cart page is worth it if a real share of your traffic still goes there rather than to a drawer. You can use more than one: the code registers every instance on the page and updates all of them from a single cart request, so two bars cost one network call, not two.
The code above is a normal free shipping bar and it will serve a single-currency store unchanged. Teams come to us when the threshold stops being one number: a different amount per market, heavy or oversized items that should not count toward it, free shipping restricted to wholesale or tagged customers, or a ladder of offers where free shipping is only the first rung.
We build Shopify themes and apps, and we have shipped three of our own to the Shopify App Store — one of them, Xperts Volume Discount, exists because threshold-based offers are a job merchants kept asking us to do by hand. Tell us what you are trying to do and our team will go through the requirement with you — what it actually involves, what you do not need, and the simplest way to get there. This is paid work, and we will set it up at the lowest cost it can be done properly for. You will know the price before anything starts.
Reply within a few hours · We go through the requirement with you first