Shopify Free Shipping Bar Generator
Show shoppers how close they are to free shipping, with a progress bar that updates as the cart changes and handles other currencies.
Your details
Your section file (sections/custom-free-shipping-bar.liquid)
{%- comment -%}
Free shipping progress bar. Reads cart.total_price (after discounts, in the shopper's currency, in cents)
and updates itself when the cart changes. Add it to the Header group or to your cart page.
{%- endcomment -%}
{%- liquid
assign threshold_cents = 0
if section.settings.threshold != blank
assign threshold_cents = section.settings.threshold | times: 100 | round
endif
assign cart_currency = cart.currency.iso_code
assign threshold_known = false
if cart_currency == shop.currency
assign threshold_known = true
endif
if section.settings.local_thresholds != blank
assign pairs = section.settings.local_thresholds | remove: ' ' | upcase | split: ','
for pair in pairs
assign parts = pair | split: ':'
if parts[0] == cart_currency and parts[1] != blank
assign threshold_cents = parts[1] | times: 100 | round
assign threshold_known = true
endif
endfor
endif
if threshold_cents <= 0
assign threshold_known = false
endif
assign total = cart.total_price
assign remaining = threshold_cents | minus: total
assign remaining_money = remaining | money
assign threshold_money = threshold_cents | money
assign percent = 0
if threshold_cents > 0
assign percent = total | times: 100 | divided_by: threshold_cents
if percent > 100
assign percent = 100
endif
endif
assign hide_bar = false
if cart.item_count == 0 and section.settings.show_when_empty == false
assign hide_bar = true
endif
-%}
{%- style -%}
#FreeShippingBar-{{ section.id }}{display:block;background:{{ section.settings.background }};color:{{ section.settings.text_color }};padding:.75rem 1rem}
#FreeShippingBar-{{ section.id }}[hidden]{display:none}
#FreeShippingBar-{{ section.id }} .ea-fsb__inner{max-width:640px;margin:0 auto}
#FreeShippingBar-{{ section.id }} .ea-fsb__text{margin:0 0 .5rem;text-align:center;font-size:.95em}
#FreeShippingBar-{{ section.id }} .ea-fsb__track{height:8px;border-radius:999px;background:{{ section.settings.track_color }};overflow:hidden}
#FreeShippingBar-{{ section.id }} .ea-fsb__fill{display:block;height:100%;width:0;border-radius:inherit;background:{{ section.settings.bar_color }};transition:width .4s ease}
@media (prefers-reduced-motion:reduce){#FreeShippingBar-{{ section.id }} .ea-fsb__fill{transition:none}}
{%- endstyle -%}
<ea-free-shipping-bar
id="FreeShippingBar-{{ section.id }}"
class="ea-fsb"
data-threshold="{{ section.settings.threshold | default: 0 }}"
data-local="{{ section.settings.local_thresholds | escape }}"
data-shop-currency="{{ shop.currency }}"
data-currency="{{ cart_currency }}"
data-total="{{ cart.total_price }}"
data-count="{{ cart.item_count }}"
data-known="{{ threshold_known }}"
data-show-empty="{{ section.settings.show_when_empty }}"
data-message="{{ section.settings.message | escape }}"
data-success="{{ section.settings.success_message | escape }}"
data-empty="{{ section.settings.empty_message | escape }}"
{% if hide_bar %}hidden{% endif %}
>
<div class="ea-fsb__inner">
<p class="ea-fsb__text" aria-live="polite">
{%- if threshold_known -%}
{%- if cart.item_count == 0 -%}
{{- section.settings.empty_message | escape | replace: '[threshold]', threshold_money -}}
{%- elsif remaining > 0 -%}
{{- section.settings.message | escape | replace: '[amount]', remaining_money -}}
{%- else -%}
{{- section.settings.success_message | escape -}}
{%- endif -%}
{%- endif -%}
</p>
<div class="ea-fsb__track" role="progressbar" aria-label="Progress to free shipping" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ percent }}">
<span class="ea-fsb__fill" style="width: {{ percent }}%;"></span>
</div>
</div>
</ea-free-shipping-bar>
<script>
if (!customElements.get('ea-free-shipping-bar')) {
customElements.define('ea-free-shipping-bar', class extends HTMLElement {
connectedCallback() {
if (this.dataset.known !== 'true') {
this.render({ total_price: Number(this.dataset.total), currency: this.dataset.currency, item_count: Number(this.dataset.count) });
}
// Refresh whenever the theme adds, changes or clears cart items (fetch or XHR).
if ('PerformanceObserver' in window && !this.observer) {
this.observer = new PerformanceObserver((list) => {
if (list.getEntries().some((e) => /\/cart\/(add|change|update|clear)/.test(e.name))) this.refresh();
});
try { this.observer.observe({ type: 'resource' }); } catch (e) {}
}
this.onShow = () => this.refresh();
window.addEventListener('pageshow', this.onShow);
}
disconnectedCallback() {
if (this.observer) this.observer.disconnect();
this.observer = null;
window.removeEventListener('pageshow', this.onShow);
}
refresh() {
clearTimeout(this.wait);
this.wait = setTimeout(() => {
const root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
fetch(root + 'cart.js', { headers: { Accept: 'application/json' } })
.then((r) => r.json())
.then((cart) => this.render(cart))
.catch(() => {});
}, 150);
}
thresholdFor(currency) {
const pairs = (this.dataset.local || '').replace(/\s/g, '').toUpperCase().split(',');
for (const pair of pairs) {
const [code, amount] = pair.split(':');
if (code === currency && amount && !isNaN(parseFloat(amount))) return Math.round(parseFloat(amount) * 100);
}
const base = parseFloat(this.dataset.threshold) * 100;
if (!base) return null;
if (currency === this.dataset.shopCurrency) return Math.round(base);
// Shopify Markets: convert the store-currency threshold with the storefront's live rate.
const rate = window.Shopify && window.Shopify.currency && parseFloat(window.Shopify.currency.rate);
return rate ? Math.round(base * rate) : null;
}
format(cents, currency) {
try {
return new Intl.NumberFormat(document.documentElement.lang || undefined, { style: 'currency', currency }).format(cents / 100);
} catch (e) {
return (cents / 100).toFixed(2) + ' ' + currency;
}
}
render(cart) {
const threshold = this.thresholdFor(cart.currency);
if (!threshold) { this.hidden = true; return; }
const total = Number(cart.total_price) || 0;
const remaining = threshold - total;
const pct = Math.max(0, Math.min(100, Math.floor((total / threshold) * 100)));
let text;
if (!cart.item_count) {
if (this.dataset.showEmpty !== 'true') { this.hidden = true; return; }
text = this.dataset.empty.replace('[threshold]', this.format(threshold, cart.currency));
} else if (remaining > 0) {
text = this.dataset.message.replace('[amount]', this.format(remaining, cart.currency));
} else {
text = this.dataset.success;
}
this.hidden = false;
this.querySelector('.ea-fsb__text').textContent = text;
this.querySelector('.ea-fsb__fill').style.width = pct + '%';
this.querySelector('[role="progressbar"]').setAttribute('aria-valuenow', String(pct));
}
});
}
</script>
{% schema %}
{
"name": "Free shipping bar",
"tag": "section",
"class": "ea-free-shipping-bar-section",
"settings": [
{
"type": "number",
"id": "threshold",
"label": "Free shipping threshold",
"info": "In your store currency (USD). Match the minimum on your free shipping rate.",
"default": 75
},
{
"type": "text",
"id": "local_thresholds",
"label": "Fixed thresholds in other currencies",
"info": "Optional. For markets with their own free shipping rate, for example EUR:70, GBP:60. Other currencies are converted automatically."
},
{
"type": "text",
"id": "message",
"label": "Message below the threshold",
"info": "[amount] is replaced with the amount left to spend.",
"default": "You're [amount] away from free shipping"
},
{
"type": "text",
"id": "success_message",
"label": "Message once reached",
"default": "You've unlocked free shipping!"
},
{
"type": "checkbox",
"id": "show_when_empty",
"label": "Show when the cart is empty",
"default": true
},
{
"type": "text",
"id": "empty_message",
"label": "Empty cart message",
"info": "[threshold] is replaced with the threshold.",
"default": "Free shipping on orders over [threshold]"
},
{
"type": "header",
"content": "Colors"
},
{
"type": "color",
"id": "background",
"label": "Background",
"default": "#f4f1ea"
},
{
"type": "color",
"id": "text_color",
"label": "Text",
"default": "#1a1a1a"
},
{
"type": "color",
"id": "bar_color",
"label": "Progress",
"default": "#2e7d4f"
},
{
"type": "color",
"id": "track_color",
"label": "Track",
"default": "#dcd6c8"
}
],
"presets": [
{
"name": "Free shipping bar"
}
]
}
{% endschema %}
To install: in Shopify admin go to Online Store → Themes, click … next to your theme → Edit code. Under Sections choose Add a new section, name it as shown above, replace the contents with this code and save. Then open Customize and add the section where you want it. Try it on a duplicate of your theme first. Put it in the Header group to show it on every page, or on the cart page template.
Other currencies: shoppers paying in USD see the threshold as entered. Any currency listed under fixed thresholds uses that amount. Every other currency is converted with the storefront's exchange rate (Shopify.currency.rate), so the bar can be a few cents off the converted threshold Shopify uses at checkout. For an exact match, set a local free shipping rate for that market and list it here.
Live preview
How the bar works out what's left to spend
Shopify gives the cart total as cart.total_price: the price after discounts, in the shopper's currency, in cents. The bar compares it with your threshold and fills in the gap.
The generator's default
- Free shipping threshold
- $75.00
- Cart total
- $48.00
The bar says You're $27.00 away from free shipping and is 64% full (48 ÷ 75).
When a shopper adds, changes or removes an item, the bar notices the theme's cart request and reloads /cart.js, so it stays right in cart drawers and on the cart page without a refresh.
Other currencies with Shopify Markets
With Markets, a shopper in Germany sees the cart in euros, so a $75 threshold has to be shown in euros too. The bar picks the threshold in this order:
- Your store currency: the threshold exactly as you entered it.
- Currencies you list under fixed thresholds, such as
EUR:70: that amount. Use this for markets where you set a local free shipping rate, so the bar matches checkout exactly. - Any other currency: your threshold converted with the storefront's exchange rate (
Shopify.currency.rate). If the rate were 0.92, $75 would show as €69.00.
Shopify converts prices with the exchange rate plus a conversion fee, then applies rounding if you've turned it on. The converted threshold on the bar can therefore be a few cents off what checkout uses. If that matters, set a local free shipping rate for the market and list it as a fixed threshold.
Adding it to your store
- 01
Check your rate
In Settings → Shipping and delivery, open your shipping profile and make sure your free shipping rate uses the same minimum as the bar.
- 02
Add the section
In Online Store → Themes → … → Edit code, add a new section named
custom-free-shipping-barand paste the code. - 03
Place it
In Customize, add Free shipping bar to the Header group to show it on every page, or to your cart page template.
Not sure where to set the threshold? The free shipping threshold calculator finds the break-even point from your order value and margin.
Questions people ask
Does the bar update without reloading the page?
Yes. It watches for the theme's add, change, update and clear cart requests, then reloads the cart and redraws itself.
Does it count discounts?
Yes. cart.total_price is the total after discounts, so a discount code that drops the cart below the threshold shows the shopper how much they still need.
Why is the converted amount slightly different from checkout?
Shopify adds a conversion fee and can round converted prices, and the storefront rate doesn't reflect every rule. List a fixed local threshold for markets where you need an exact match.
Can I put it inside my cart drawer?
Theme cart drawers are snippets, so the simplest option is the Header group, which is visible while the drawer is open. Placing it inside a drawer needs a theme edit specific to your theme.
