Free Shipping Progress Bar in Liquid (No App, Markets-Safe)

A correct free shipping progress bar in Liquid renders its initial state on the server through the money filter, listens to the theme’s cart change event for live updates instead of polling /cart.js, derives the threshold from theme settings or a money metafield (never a hardcoded number), and survives Shopify Markets currency conversion because both threshold and cart total flow through the same money filter chain.

TL;DR: One section, one JS module, one CSS block, zero apps. The threshold lives in theme settings, the bar paints correctly on first render in Liquid, JavaScript listens to the theme’s cart event for live updates, and the whole thing survives Markets currency conversion because every money value is piped through the money filter. 90 lines of code, $0 per month, replaces a $19 app.

I built this for a DTC client doing roughly $500K a year expanding into three Shopify Markets currencies. They had a free shipping bar app that worked in USD and silently broke in EUR and GBP. The threshold copy still said “Spend $25 more for free shipping” while the cart displayed euros. The progress fill stuck at zero because the bundled JavaScript was comparing a USD cents number against a EUR cents number. The merchant was paying $19 a month for a feature actively confusing 30% of their customers.

The replacement is a Liquid section. 90 lines of code, $0 per month, handles three things the app got wrong: Markets-aware currency rendering, cart drawer integration, and graceful degradation when JS is blocked. The companion piece for the broader Liquid-replaces-apps argument is the snippets that replace apps post. The same currency pattern is in the multi-currency hardcoded prices fix and shipped across Everly’s Markets rollout where 38 hardcoded prices got swapped to money filters.

Why most Shopify free shipping bars are broken

The typical tutorial fetches /cart.js on page load, hardcodes the threshold as a JavaScript number, compares it against cart.total_price in raw cents, and writes a string into the DOM with the dollar sign baked into the template literal. Every assumption breaks under Shopify Markets, breaks under cart drawer interactions, and ships layout shift on every navigation.

The first failure is the threshold. When you write const THRESHOLD = 7500, you have hardcoded seventy-five US dollars in cents. The moment the storefront renders in EUR, the cart total comes back in EUR cents and your comparison is meaningless.

The second failure is the data source. Polling /cart.js on page load is fine for initial render but ignores the cart drawer. When a customer adds a product through the drawer, the drawer mutates the cart and dispatches an event. The polling pattern does not see that event, so the bar stays stale until the next full navigation.

The third failure is the rendering. Tutorials that build the bar entirely in JavaScript hide an empty container, fetch the cart, then inject HTML once the response resolves. That ships a Cumulative Layout Shift score against the page and breaks completely when JavaScript is blocked. The fix is to render correct initial state in Liquid before any JavaScript runs.

Where the threshold lives

Use settings.free_shipping_threshold as a number theme setting for single-currency stores or stores where the threshold should auto-convert via Markets. Use a shop-level money metafield for stores that need a hand-set threshold per market. Never put the number in Liquid as a literal and never put it in JavaScript at all.

The settings approach works for the majority of merchants. In config/settings_schema.json you add a number-type setting with a sensible default. Liquid reads it via settings.free_shipping_threshold. The merchant changes the threshold in the theme editor without code. When the storefront switches presentment currency through Markets, Shopify converts the value using the same conversion rate it applies to product prices.

The metafield approach is for merchants who want to hand-tune per market. A common pattern is “$50 in the US, €60 in the EU, £45 in the UK”. Markets auto-conversion would give you €43.20 at a 0.864 rate, which is not what the merchant wants. A money metafield scoped at the shop level with values keyed by market lets the merchant set each threshold deliberately.

Hardcoded numbers in Liquid couple shipping policy to deploy schedule. Hardcoded numbers in JavaScript break Markets entirely. Use the theme setting pattern below; the metafield variant is a one-line swap.

The Liquid: server-rendered initial state

The Liquid section computes the threshold in cents, the remaining amount in cents, and the progress percentage on the server. It renders the bar with correct initial copy, correct initial fill width, and the threshold-in-cents value as a data attribute the JavaScript will read. No JavaScript is required for the initial render to be correct.

{% comment %}
  sections/free-shipping-bar.liquid
  Free shipping progress bar, Markets-safe, no app, no /cart.js polling.
{% endcomment %}

{%- liquid
  assign threshold_cents = settings.free_shipping_threshold | times: 100
  assign cart_total = cart.total_price
  assign remaining_cents = threshold_cents | minus: cart_total
  if remaining_cents < 0
    assign remaining_cents = 0
  endif
  assign progress_percent = 0
  if threshold_cents > 0
    assign progress_percent = cart_total | times: 100 | divided_by: threshold_cents
  endif
  if progress_percent > 100
    assign progress_percent = 100
  endif
  assign qualified = false
  if cart_total >= threshold_cents
    assign qualified = true
  endif
-%}

<aside
  class="fsb"
  role="status"
  aria-live="polite"
  data-free-shipping-bar
  data-threshold-cents="{{ threshold_cents }}"
  data-currency="{{ cart.currency.iso_code }}">
  <p class="fsb__message" data-fsb-message>
    {%- if qualified -%}
      {{ 'sections.free_shipping_bar.qualified' | t }}
    {%- else -%}
      {{ 'sections.free_shipping_bar.remaining_html' | t: amount: remaining_cents | money }}
    {%- endif -%}
  </p>
  <div class="fsb__track" aria-hidden="true">
    <div class="fsb__fill" data-fsb-fill style="width: {{ progress_percent }}%"></div>
  </div>
</aside>

{% schema %}
{
  "name": "Free shipping bar",
  "settings": [
    { "type": "number", "id": "free_shipping_threshold", "label": "Free shipping threshold", "default": 75, "info": "In the store's base currency. Markets will convert." }
  ],
  "presets": [{ "name": "Free shipping bar" }]
}
{% endschema %}

The threshold gets multiplied by 100 once to convert merchant-friendly decimal to Shopify cents. Every comparison from that point uses cents because cart.total_price is also cents. The remaining_cents | money pipe is the Markets-safe formatter, identical to how Shopify renders product prices. The qualified flag is computed in Liquid so the message switches to the success copy on the server, not after a JavaScript repaint.

The data attributes are the JavaScript handshake. data-threshold-cents carries the integer the JS uses for math. data-currency is the active presentment currency ISO code, used for sanity checks and for choosing the right Intl.NumberFormat locale if you format remaining amounts on the client.

The JS: event-driven updates

The JavaScript binds one listener to the theme’s cart change event, reads the new cart total from the event payload, recomputes the percentage and remaining amount against the threshold from the data attribute, and writes the result back to the DOM. No setInterval, no polling, no fetch on page load.

// assets/free-shipping-bar.js
(function () {
  const root = document.querySelector('[data-free-shipping-bar]');
  if (!root) return;
  const fill = root.querySelector('[data-fsb-fill]');
  const message = root.querySelector('[data-fsb-message]');
  const thresholdCents = parseInt(root.dataset.thresholdCents, 10);
  if (!Number.isFinite(thresholdCents) || thresholdCents <= 0) return;

  const formatMoney = (cents) => {
    if (window.Shopify && typeof window.Shopify.formatMoney === 'function') {
      return window.Shopify.formatMoney(cents, window.theme && window.theme.moneyFormat);
    }
    return (cents / 100).toFixed(2);
  };

  const render = (totalCents) => {
    const remaining = Math.max(thresholdCents - totalCents, 0);
    const percent = Math.min((totalCents / thresholdCents) * 100, 100);
    fill.style.width = percent + '%';
    message.textContent = totalCents >= thresholdCents
      ? 'You qualify for free shipping.'
      : `Spend ${formatMoney(remaining)} more for free shipping.`;
  };

  const totalFromEvent = (event) => {
    const detail = (event && event.detail) || {};
    if (typeof detail.cartTotal === 'number') return detail.cartTotal;
    if (detail.cart && typeof detail.cart.total_price === 'number') return detail.cart.total_price;
    return null;
  };

  const handleCartChange = (event) => {
    const cents = totalFromEvent(event);
    if (cents !== null) return render(cents);
    fetch('/cart.js', { headers: { Accept: 'application/json' } })
      .then((r) => r.json()).then((cart) => render(cart.total_price))
      .catch(() => {});
  };

  document.addEventListener('cart:updated', handleCartChange);
  document.addEventListener('cart-update', handleCartChange);
})();

The fallback fetch is intentional and rare. If the theme’s event payload includes the cart object, the function uses it with no network round trip. If the event is a notification without payload, the function fetches once. It does not poll on a timer.

The formatMoney resolver prefers Shopify.formatMoney because it uses the merchant’s actual money format string, the same pipeline the rest of the storefront uses. If the theme has not exposed it, the fallback formats to two decimals without a currency symbol, which is a deliberate choice: a wrong symbol is worse than no symbol.

Multi-currency handling

With Shopify Markets, cart.total_price (see the cart object reference) is always in the active presentment currency cents. The threshold passed through settings.free_shipping_threshold | times: 100 is auto-converted by Shopify when the shop renders in a non-base currency, because number theme settings with a currency unit follow the same Markets conversion path as product prices. This is why the section above is Markets-safe without any client-side currency math.

If you opt for the metafield variant, the metafield should be a money type, not a number. Money metafields carry the currency code and Shopify converts them through the Markets pipeline. Reading a raw number metafield bypasses conversion and reintroduces the bug we are solving.

Do not try to convert currencies in JavaScript on the fly. That path leads to stale exchange rates and merchant complaints. The data attribute carrying thresholdCents is correct for the current page render; if the customer switches market, the page reloads and the section re-renders with the new cents value.

The CSS

/* assets/free-shipping-bar.css */
.fsb { display: block; padding: 12px 16px; background: var(--fsb-bg, #faf7f2); color: var(--fsb-fg, #1a1a1a); font: 500 14px/1.4 system-ui, sans-serif; text-align: center; }
.fsb__message { margin: 0 0 8px; }
.fsb__track { position: relative; height: 6px; border-radius: 999px; background: rgba(0,0,0,0.08); overflow: hidden; }
.fsb__fill { height: 100%; border-radius: inherit; background: var(--fsb-fill, #1a8754); transition: width 240ms ease-out; will-change: width; }
@media (prefers-reduced-motion: reduce) { .fsb__fill { transition: none; } }

The reserved height on .fsb__track is the only fixed metric, intentional. A 6px track is large enough to be visible and small enough to never push the page on resize. The fill uses transition: width because width animates within a fixed-height parent, so there is no layout cost beyond paint. The prefers-reduced-motion block respects accessibility preferences without removing the bar.

Cart drawer integration

The progress bar binds to the cart change event the theme already dispatches when the drawer fetches a new cart. You do not fork the drawer, you do not patch theme.js, and you do not call /cart.js from the drawer because the drawer is already calling it.

In Dawn, the drawer fetches /cart/add.js and dispatches cart-update on document when the response resolves. In Horizon and most premium themes, the dispatched event is cart:updated. The handler above listens to both. Pick the one your theme uses by searching theme.js for the dispatchEvent call; change the listener to match if your theme dispatches a different name.

If your theme is older and does not dispatch a cart event at all, the cleanest patch is to wrap the drawer’s add-to-cart fetch in a small intercept that re-dispatches a CustomEvent after the existing handler resolves. The intercept is two lines, lives next to the existing fetch call, and is easy to remove when the merchant upgrades the theme.

For the broader friction layer that pairs with a progress bar, reduce Shopify checkout abandonment covers 12 data-backed fixes including this exact pattern as Fix 8. The cart abandonment hidden causes post covers root-cause analysis for the deeper drop-off points.

Sticky bars sit above the header on every page. Drawer-only bars appear only inside the cart drawer. Sticky converts better on average but costs vertical space on mobile. Drawer-only is the right choice for premium catalogs where shouting about $5 shipping cheapens the brand.

The section above works in both contexts. Place it in theme.liquid above the header for sticky, or inside cart-drawer.liquid for drawer-only. The Liquid logic and JavaScript are identical because both contexts read from the same cart object and listen to the same event.

For most DTC merchants below $1M revenue, sticky on mobile and desktop is correct because the goal is to amortize shipping cost across higher AOV. For brands above $1M with luxury positioning, drawer-only is usually right. Everly sat in the second bucket; same Liquid section, different placement, zero code differences. That is the value of the architecture.

Accessibility

The bar uses role="status" and aria-live="polite" so screen readers announce message changes without interrupting the current focus. The track is aria-hidden="true" because fill width does not communicate anything beyond what the message text already says. Keyboard users get nothing to interact with because the bar is informational, not interactive.

The polite live region is the right level. Assertive would interrupt every cart change with an announcement, which is loud. The fill animation respects prefers-reduced-motion. Color contrast on the fill against the track must clear WCAG 2.1 AA at 4.5:1; the --fsb-fill and --fsb-track CSS custom properties make the contrast swap trivial.

How to verify the bar works in 5 minutes

Three checks before shipping to production.

  1. Initial state in incognito. Open the storefront in an incognito window with cart empty. The bar shows “Spend $75.00 more for free shipping” and the fill is at 0%. Add a $30 product. The bar updates to “Spend $45.00 more” and the fill jumps to 40%. If the math is off, the threshold setting is wrong or cart.total_price is not in cents in your theme context.
  2. Drawer interaction. Open the cart drawer. Add another item from a product page using the drawer’s quick-add. The bar should update inside the drawer without a page reload. If it does not, your theme is dispatching a different event name; search theme.js for dispatchEvent and update the listener.
  3. Markets currency switch. If you run Shopify Markets, switch to a non-base currency via the country selector. The threshold copy should show the converted amount and the fill should compute against the converted cart total. If the threshold still shows USD, the theme setting is reading the raw number instead of the converted value; switch to the money metafield variant.

Apps vs Liquid vs naive JS

Concern This Liquid section Free shipping bar app Naive JS tutorial
Monthly cost $0 $9 to $29 $0
Markets-safe currency Yes (money filter) Usually no No
Cart drawer updates Yes (cart event) Sometimes No
Layout shift on load None, server-rendered Frequent Always
Works without JS Yes No No
Locked into vendor No Yes No

A merchant on Shopify Basic at $39 a month adding a $19 free shipping app pays half their base plan toward one feature. Replacing it with the Liquid section recovers $19 monthly and removes a third-party dependency from page load. Across a year, $228, roughly one developer hour.

The reliability calculation matters more. App-based bars break when Shopify ships theme architecture changes, when Markets adds a new currency, or when the vendor updates their script tag. The Liquid section breaks only when Shopify changes Liquid, which rarely happens in breaking ways. The merchant trades a recurring fee for a feature they own.

The takeaway

  • Render the initial state in Liquid through money. JavaScript enhances, it does not bootstrap.
  • Read the threshold from settings.free_shipping_threshold or a money metafield. Never hardcode the number in Liquid or JavaScript.
  • Listen to the theme’s cart event (cart-update in Dawn, cart:updated in Horizon). Do not poll /cart.js on a timer.
  • Pipe every money value through the same money filter chain so Markets currency conversion stays consistent across threshold, remaining amount, and cart total.
  • Test in incognito, in the cart drawer, and in a non-base Markets currency. Most bugs hide in one of those three states.

Need a custom free shipping bar that survives Shopify Markets, or the full custom Liquid section examples and checkout optimization guide pattern set wired up for your store? Book a free 30-minute build call.

Frequently Asked Questions

Why does my free shipping progress bar break in Shopify Markets currency?

Most tutorials hardcode the threshold as a literal number in JavaScript and compare it against cart.total_price in cents. When Markets renders the cart in EUR or GBP, the threshold stays USD while the cart total is in presentment cents, so the bar progresses against the wrong baseline. Fix: derive the threshold from a theme setting or money metafield and pipe both numbers through the money filter on the server.

Should I use cart.js polling or the cart event API to update the bar?

The cart event API. Dawn, Horizon, and most premium themes dispatch a custom event whenever the cart mutates, which is the only pattern that handles the cart drawer correctly. Polling /cart.js wastes a request per navigation, races with the theme's own fetches, and skips the drawer entirely.

Where should the free shipping threshold live?

Theme settings is the default for single-store merchants because Liquid reads it via settings.free_shipping_threshold and the merchant edits the value without code. A money metafield is the right choice when the threshold varies per market or customer tag. Hardcoded numbers in Liquid or JavaScript are always wrong.

Will this progress bar cause CLS if it loads after the rest of the page?

Not if you render the initial state in Liquid on the server. The bar should be in the DOM with its final width populated from cart.total_price before any JavaScript runs. JavaScript only takes over to update the bar in response to cart change events.

How do I make the bar work with my theme's cart drawer without forking it?

Listen to the cart event your theme already dispatches when the drawer updates. Dawn fires cart-update on document; Horizon and most premium themes fire cart:updated. Bind one listener to whichever name your theme uses. Do not poll, do not patch the drawer, and do not call /cart.js yourself because that creates a race with the drawer's own fetch.

Does this degrade gracefully when JavaScript is blocked?

Yes, because the initial state is server-rendered. With JS off, the customer still sees a correct progress bar reflecting cart total at page render. They lose live updates on quantity change, but the bar is not invisible and the threshold is not blank. App-based bars almost universally fail this test because they bootstrap entirely from a third-party script tag.

Book Strategy Call