# Free shipping bar: free Shopify section

> "You're $23 away from free shipping" with a progress bar, from the live cart and your threshold. Updates with cart drawers, per-currency thresholds, no app.

Free shipping bar is a free Shopify section that tells shoppers how far they are from free shipping, such as "You're $23.00 away from free shipping", with a progress bar. It works out the amount from the cart total and the threshold you type, renders it with Liquid on page load and reads the cart again (/cart.js) whenever the theme adds, changes or clears items, so it follows a cart drawer. Thresholds can be set per currency for Shopify Markets. It only displays progress: the free shipping itself is a shipping rate or discount you set up in Shopify. It works in Online Store 2.0 themes without an app.

- **Category:** Conversion and offers
- **Use on:** Every page, Cart, Product
- **Reads:** Cart (cart object and /cart.js)
- **Size:** 17 KB in 1 file, with a small script
- **License:** MIT, © 2026 Lintel
- **Page:** https://lintelagency.com/resources/shopify-sections/free-shipping-bar
- **Live demo:** https://lintelagency.com/resources/shopify-sections/demo/lib-free-shipping-bar.html
- **Added:** 2026-11-02

## What it’s for

- A slim strip at the top of every page while a free shipping offer runs
- A box above the cart items on the cart page
- A line under the buy box on product pages, close to the add to cart button

## Editor settings

In the theme editor the section is called “Lib · Free shipping bar”. IDs, types, labels and defaults come from the section’s schema; the labels are what the theme editor shows.

### Threshold

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `threshold` | `number` | Free shipping from (store currency) | `75` |  |

### Messages

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `message_progress` | `text` | Before the threshold | You're {remaining} away from free shipping |  |
| `message_success` | `text` | Threshold reached | Your order ships free |  |
| `message_empty` | `text` | Empty cart | Free shipping on orders over {threshold} |  |
| `progress_label` | `text` | Progress bar name (screen readers) | Progress to free shipping |  |
| `show_when_empty` | `checkbox` | Show when the cart is empty | `true` |  |
| `hide_when_reached` | `checkbox` | Hide once free shipping is reached | `false` |  |

### Layout

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `style` | `select` | Style | strip | `strip`, `card` |
| `show_icon` | `checkbox` | Show delivery icon | `true` |  |
| `show_progress` | `checkbox` | Show progress bar | `true` |  |
| `section_width` | `select` | Width | page | `page`, `full` |
| `text_alignment` | `select` | Alignment on desktop | center | `left`, `center` |
| `text_alignment_mobile` | `select` | Alignment on mobile | center | `left`, `center` |

### Colors

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `color_bg` | `color` | Background | #F5EFE6 |  |
| `color_bg_gradient` | `color_background` | Background gradient | – |  |
| `color_text` | `color` | Text | #1F1B17 |  |
| `color_accent` | `color` | Progress and icon | #4F6B3A |  |
| `color_track` | `color` | Progress track | #E2D9CC |  |
| `color_card` | `color` | Box background | #FFFFFF |  |

### Spacing and visibility

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `padding_top` | `range` | Top padding on desktop | `16` | 0–120 px, step 4 |
| `padding_bottom` | `range` | Bottom padding on desktop | `16` | 0–120 px, step 4 |
| `padding_top_mobile` | `range` | Top padding on mobile | `12` | 0–120 px, step 4 |
| `padding_bottom_mobile` | `range` | Bottom padding on mobile | `12` | 0–120 px, step 4 |
| `show_on` | `select` | Show on | all | `all`, `desktop`, `mobile` |
| `anchor_id` | `text` | Anchor ID | – |  |

### Motion

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `animate` | `checkbox` | Animate the progress bar | `true` |  |

## Blocks

Up to 20 blocks in total.

### Currency threshold (`currency`)

| ID | Type | Label | Default | Options or range |
| --- | --- | --- | --- | --- |
| `currency` | `text` | Currency code | EUR |  |
| `amount` | `number` | Free shipping from | `70` |  |

## Install

### In Shopify admin

1. Work on a copy, not your live theme: in Shopify admin go to **Online Store > Themes**, open the **…** menu on your current theme and choose **Duplicate**.
2. On the copy, open the **…** menu and choose **Edit code**.
3. In the `sections` folder, add a new file named `lib-free-shipping-bar.liquid` and paste the contents of `sections/lib-free-shipping-bar.liquid` from the source below. Save.
4. Open **Customize** on the copy, choose **Add section** and pick **Lib · Free shipping bar**. Replace the sample content with your own and set the colors.
5. Preview the copy, then publish it when it looks right.

### With Shopify CLI

From your local theme folder, download the files and push only them to an unpublished theme (find its ID with `shopify theme list`):

```sh
curl -fsSL -o sections/lib-free-shipping-bar.liquid https://lintelagency.com/resources/shopify-sections/files/lib-free-shipping-bar.liquid
shopify theme push --theme <unpublished-theme-id> --only sections/lib-free-shipping-bar.liquid
```

Never push to the live theme: always pass the ID of an unpublished theme and never use `--live` or `--allow-live`. Then add the section in the theme editor as above.

## Files

- [sections/lib-free-shipping-bar.liquid](https://lintelagency.com/resources/shopify-sections/files/lib-free-shipping-bar.liquid): goes in `sections/`

## Questions

### Does this section give customers free shipping?

No. It only shows progress towards the threshold you type. Set up the free shipping itself in Shopify: a free rate with a minimum order price under Settings > Shipping and delivery, or a free shipping discount under Discounts, using the same amount.

### Which cart total does it use?

The cart total Shopify reports (total_price): after cart discounts, before shipping and taxes. If your shipping rate condition uses a different total, set the threshold so the two match.

### Does it update when items are added in a cart drawer?

Yes. The script notices finished requests to Shopify's cart endpoints (add, change, update, clear) and the cart:updated events many themes send, then reads /cart.js again and updates the message and bar.

### How does it handle other currencies?

Add a Currency threshold block with the currency code and an exact amount, for example EUR 70. Without one, the store's threshold is converted at Shopify's current rate, which can give an uneven number.

### Can I hide it once free shipping is reached, or on an empty cart?

Yes, both are settings. The messages for an empty cart, progress and success are text you can change and translate, with {remaining} and {threshold} placeholders.

## Source

### sections/lib-free-shipping-bar.liquid

```liquid
{%- comment -%}
  MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections
{%- endcomment -%}
{%- comment -%}
  Free shipping bar: "You're $23.00 away from free shipping" with a progress bar, worked out from
  the cart total and the threshold you type. Liquid renders it from the cart on page load; a small
  script reads /cart.js again whenever the theme changes the cart (add, change, update, clear), so
  the message follows a cart drawer. Thresholds can be set per currency for Shopify Markets;
  otherwise the store's threshold is converted at Shopify's rate. The section only displays: the
  free shipping itself must be set up in Shopify (shipping rates or a free shipping discount).
  Depends on: nothing
{%- endcomment -%}

{%- liquid
  assign s = section.settings
  assign currency = cart.currency.iso_code | default: shop.currency
  assign threshold = s.threshold | times: 100 | round
  assign exact = false
  if currency == shop.currency
    assign exact = true
  endif
  for block in section.blocks
    assign code = block.settings.currency | strip | upcase
    if code == currency and block.settings.amount > 0
      assign threshold = block.settings.amount | times: 100 | round
      assign exact = true
    endif
  endfor

  assign total = cart.total_price | default: 0
  assign remaining = threshold | minus: total
  assign state = 'progress'
  if cart.item_count == 0
    assign state = 'empty'
  elsif remaining <= 0
    assign state = 'done'
    assign remaining = 0
  endif
  assign percent = 0
  if threshold > 0
    assign percent = total | times: 100 | divided_by: threshold
  endif
  if percent > 100
    assign percent = 100
  endif

  assign hide = false
  if state == 'empty' and s.show_when_empty == false
    assign hide = true
  endif
  if state == 'done' and s.hide_when_reached
    assign hide = true
  endif
  # Another currency without its own threshold: the script converts at Shopify's rate first.
  if exact == false
    assign hide = true
  endif
  if request.design_mode
    assign hide = false
  endif

  assign message = s.message_progress
  if state == 'empty'
    assign message = s.message_empty
  elsif state == 'done'
    assign message = s.message_success
  endif
  assign remaining_money = remaining | money
  assign threshold_money = threshold | money
  assign remaining_html = '<strong>' | append: remaining_money | append: '</strong>'
  assign threshold_html = '<strong>' | append: threshold_money | append: '</strong>'
  assign message = message | escape | replace: '{remaining}', remaining_html | replace: '{threshold}', threshold_html
-%}

<lib-free-shipping-bar
  class="lib-ship-bar lib-ship-bar--{{ s.section_width }}"
  {% if s.anchor_id != blank %}
    id="{{ s.anchor_id | handleize }}"
  {% endif %}
  data-show="{{ s.show_on }}"
  data-style="{{ s.style }}"
  data-state="{{ state }}"
  data-threshold="{{ threshold }}"
  data-currency="{{ currency }}"
  data-locale="{{ request.locale.iso_code }}"
  data-root="{{ routes.root_url }}"
  {% unless exact %}
    data-convert
  {% endunless %}
  {% if s.show_when_empty == false %}
    data-hide-empty
  {% endif %}
  {% if s.hide_when_reached %}
    data-hide-done
  {% endif %}
  {% if s.animate %}
    data-animate
  {% endif %}
  {% if hide %}
    hidden
  {% endif %}
  style="
    --lib-bg: {{ s.color_bg }};
    {% if s.color_bg_gradient != blank %}--lib-bg-image: {{ s.color_bg_gradient }};{% endif %}
    --lib-text: {{ s.color_text }};
    --lib-accent: {{ s.color_accent }};
    --lib-track: {{ s.color_track }};
    --lib-card: {{ s.color_card }};
    --lib-pt: {{ s.padding_top }}px;
    --lib-pb: {{ s.padding_bottom }}px;
    --lib-pt-m: {{ s.padding_top_mobile }}px;
    --lib-pb-m: {{ s.padding_bottom_mobile }}px;
  "
>
  <div class="lib-ship-bar__inner" data-align="{{ s.text_alignment }}" data-align-m="{{ s.text_alignment_mobile }}">
    <div class="lib-ship-bar__row">
      {%- if s.show_icon -%}
        <svg class="lib-ship-bar__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
          <path d="M3 6h11v10H3zM14 9h4l3 3v4h-7"/><circle cx="7" cy="17.5" r="1.8"/><circle cx="17" cy="17.5" r="1.8"/>
        </svg>
      {%- endif -%}
      <p class="lib-ship-bar__message" aria-live="polite" data-message>{{ message }}</p>
    </div>
    {%- if s.show_progress -%}
      <div
        class="lib-ship-bar__track"
        role="progressbar"
        aria-label="{{ s.progress_label | escape }}"
        aria-valuemin="0"
        aria-valuemax="100"
        aria-valuenow="{{ percent }}"
        data-track
        {% if state == 'empty' %}
          hidden
        {% endif %}
      >
        <span class="lib-ship-bar__fill" style="width: {{ percent }}%;" data-fill></span>
      </div>
    {%- endif -%}
  </div>
  <template data-templates>
    <span data-key="empty">{{ s.message_empty | escape }}</span>
    <span data-key="progress">{{ s.message_progress | escape }}</span>
    <span data-key="done">{{ s.message_success | escape }}</span>
  </template>
</lib-free-shipping-bar>

{% stylesheet %}
  .lib-ship-bar {
    display: block;
    background-color: var(--lib-bg);
    background-image: var(--lib-bg-image, none);
    color: var(--lib-text);
    padding: var(--lib-pt) 1rem var(--lib-pb);
  }
  .lib-ship-bar[hidden] { display: none; }
  .lib-ship-bar__inner { max-width: 1200px; margin: 0 auto; }
  .lib-ship-bar--full .lib-ship-bar__inner { max-width: none; }
  .lib-ship-bar[data-style='card'] .lib-ship-bar__inner {
    max-width: 36rem;
    padding: clamp(1rem, 2.5vw, 1.5rem);
    border-radius: 14px;
    background: var(--lib-card);
    box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lib-text) 10%, transparent);
  }
  @media (max-width: 749px) {
    .lib-ship-bar { padding-top: var(--lib-pt-m); padding-bottom: var(--lib-pb-m); }
    .lib-ship-bar[data-show='desktop'] { display: none; }
  }
  @media (min-width: 750px) {
    .lib-ship-bar[data-show='mobile'] { display: none; }
  }

  .lib-ship-bar__row { display: flex; align-items: center; gap: 0.625rem; }
  .lib-ship-bar__icon { flex: none; width: 1.375rem; height: 1.375rem; color: var(--lib-accent); }
  .lib-ship-bar__message { margin: 0; font-size: 0.9375rem; line-height: 1.45; }
  .lib-ship-bar__message strong { font-weight: 700; }

  .lib-ship-bar__track {
    position: relative;
    height: 0.5rem;
    margin-top: 0.625rem;
    overflow: hidden;
    border-radius: 999px;
    background: var(--lib-track);
  }
  .lib-ship-bar__track[hidden] { display: none; }
  .lib-ship-bar__fill {
    position: absolute;
    inset: 0 auto 0 0;
    max-width: 100%;
    border-radius: inherit;
    background: var(--lib-accent);
  }
  @media (prefers-reduced-motion: no-preference) {
    .lib-ship-bar[data-animate] .lib-ship-bar__fill { transition: width 0.6s cubic-bezier(0.2, 0.7, 0.2, 1); }
  }

  .lib-ship-bar__inner[data-align='center'] .lib-ship-bar__row { justify-content: center; text-align: center; }
  .lib-ship-bar__inner[data-align='center'] .lib-ship-bar__track { max-width: 28rem; margin-inline: auto; }
  @media (max-width: 749px) {
    .lib-ship-bar__inner[data-align-m='left'] .lib-ship-bar__row { justify-content: flex-start; text-align: left; }
    .lib-ship-bar__inner[data-align-m='left'] .lib-ship-bar__track { max-width: none; }
    .lib-ship-bar__inner[data-align-m='center'] .lib-ship-bar__row { justify-content: center; text-align: center; }
  }
{% endstylesheet %}

{% javascript %}
  (() => {
    if (customElements.get('lib-free-shipping-bar')) return;
    // Cart Ajax API endpoints that change the cart; a finished request to one means "read the cart again".
    const CART_CHANGES = /\/cart\/(add|change|update|clear)(\.js)?(\?|$)/;
    const REFRESH_DELAY_MS = 150;
    // Events some themes and apps dispatch after a cart change.
    const CART_EVENTS = ['cart:updated', 'cart:refresh', 'cart:change'];

    class LibFreeShippingBar extends HTMLElement {
      connectedCallback() {
        this.messages = Object.fromEntries(
          [...this.querySelector('[data-templates]').content.querySelectorAll('[data-key]')].map((el) => [el.dataset.key, el.textContent]),
        );
        this.base = Number(this.dataset.threshold) || 0;
        this.refresh = this.refresh.bind(this);
        this.scheduled = null;
        const schedule = () => {
          clearTimeout(this.scheduled);
          this.scheduled = setTimeout(this.refresh, REFRESH_DELAY_MS);
        };
        if ('PerformanceObserver' in window) {
          this.observer = new PerformanceObserver((list) => {
            if (list.getEntries().some((e) => CART_CHANGES.test(new URL(e.name, location.href).pathname))) schedule();
          });
          try {
            this.observer.observe({type: 'resource', buffered: false});
          } catch (error) {
            // Old browsers without resource timing: the events below still work.
            this.observer = null;
          }
        }
        this.onEvent = schedule;
        CART_EVENTS.forEach((name) => document.addEventListener(name, this.onEvent));
        this.onShow = (event) => event.persisted && schedule();
        window.addEventListener('pageshow', this.onShow);
        if (this.hasAttribute('data-convert')) this.refresh();
      }

      disconnectedCallback() {
        this.observer?.disconnect();
        CART_EVENTS.forEach((name) => document.removeEventListener(name, this.onEvent));
        window.removeEventListener('pageshow', this.onShow);
      }

      threshold() {
        if (!this.hasAttribute('data-convert')) return this.base;
        // Shopify Markets: convert the store's threshold at the storefront's own rate.
        const rate = Number(window.Shopify?.currency?.rate) || 1;
        return Math.round(this.base * rate);
      }

      format(cents, currency) {
        try {
          return new Intl.NumberFormat(this.dataset.locale || undefined, {style: 'currency', currency}).format(cents / 100);
        } catch (error) {
          // Unknown locale or currency code: a plain number still reads correctly.
          return (cents / 100).toFixed(2);
        }
      }

      async refresh() {
        let cart;
        try {
          const response = await fetch(`${this.dataset.root || '/'}cart.js`, {headers: {Accept: 'application/json'}});
          if (!response.ok) throw new Error(`cart.js answered ${response.status}`);
          cart = await response.json();
        } catch (error) {
          // Keep the last message rather than show a wrong one.
          console.warn('Free shipping bar: the cart could not be read.', error);
          return;
        }
        const currency = cart.currency || this.dataset.currency;
        const threshold = this.threshold();
        const total = Number(cart.total_price) || 0;
        const remaining = Math.max(0, threshold - total);
        const state = cart.item_count === 0 ? 'empty' : remaining === 0 ? 'done' : 'progress';
        const percent = threshold > 0 ? Math.min(100, Math.floor((total / threshold) * 100)) : 0;
        this.render(state, percent, {remaining: this.format(remaining, currency), threshold: this.format(threshold, currency)});
      }

      render(state, percent, values) {
        this.dataset.state = state;
        const message = this.querySelector('[data-message]');
        const parts = (this.messages[state] || '').split(/(\{remaining\}|\{threshold\})/);
        message.replaceChildren(
          ...parts.map((part) => {
            const key = part.slice(1, -1);
            if (!(key in values) || part[0] !== '{') return part;
            const strong = document.createElement('strong');
            strong.textContent = values[key];
            return strong;
          }),
        );
        const track = this.querySelector('[data-track]');
        if (track) {
          track.hidden = state === 'empty';
          track.setAttribute('aria-valuenow', String(percent));
          this.querySelector('[data-fill]').style.width = `${percent}%`;
        }
        this.hidden =
          (state === 'empty' && this.hasAttribute('data-hide-empty')) || (state === 'done' && this.hasAttribute('data-hide-done'));
      }
    }
    customElements.define('lib-free-shipping-bar', LibFreeShippingBar);
  })();
{% endjavascript %}

{% schema %}
{
  "name": "Lib · Free shipping bar",
  "tag": "section",
  "class": "lib-section",
  "max_blocks": 20,
  "settings": [
    { "type": "paragraph", "content": "This bar only shows progress. Set up the free shipping itself in Settings > Shipping and delivery (a free rate with a minimum order price) or as a free shipping discount, with the same amount." },
    { "type": "header", "content": "Threshold" },
    { "type": "number", "id": "threshold", "label": "Free shipping from (store currency)", "info": "Cart total after discounts, before shipping and taxes. Add a Currency threshold block for an exact amount in another currency.", "default": 75 },

    { "type": "header", "content": "Messages" },
    { "type": "text", "id": "message_progress", "label": "Before the threshold", "info": "{remaining} is the amount left, {threshold} the threshold.", "default": "You're {remaining} away from free shipping" },
    { "type": "text", "id": "message_success", "label": "Threshold reached", "default": "Your order ships free" },
    { "type": "text", "id": "message_empty", "label": "Empty cart", "info": "{threshold} is the threshold.", "default": "Free shipping on orders over {threshold}" },
    { "type": "text", "id": "progress_label", "label": "Progress bar name (screen readers)", "default": "Progress to free shipping" },
    { "type": "checkbox", "id": "show_when_empty", "label": "Show when the cart is empty", "default": true },
    { "type": "checkbox", "id": "hide_when_reached", "label": "Hide once free shipping is reached", "default": false },

    { "type": "header", "content": "Layout" },
    {
      "type": "select",
      "id": "style",
      "label": "Style",
      "info": "Strip suits the top of a page; box suits the cart page.",
      "options": [
        { "value": "strip", "label": "Strip" },
        { "value": "card", "label": "Box" }
      ],
      "default": "strip"
    },
    { "type": "checkbox", "id": "show_icon", "label": "Show delivery icon", "default": true },
    { "type": "checkbox", "id": "show_progress", "label": "Show progress bar", "default": true },
    {
      "type": "select",
      "id": "section_width",
      "label": "Width",
      "options": [
        { "value": "page", "label": "Page width" },
        { "value": "full", "label": "Full width" }
      ],
      "default": "page"
    },
    {
      "type": "select",
      "id": "text_alignment",
      "label": "Alignment on desktop",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" }
      ],
      "default": "center"
    },
    {
      "type": "select",
      "id": "text_alignment_mobile",
      "label": "Alignment on mobile",
      "options": [
        { "value": "left", "label": "Left" },
        { "value": "center", "label": "Center" }
      ],
      "default": "center"
    },

    { "type": "header", "content": "Colors" },
    { "type": "color", "id": "color_bg", "label": "Background", "default": "#F5EFE6" },
    { "type": "color_background", "id": "color_bg_gradient", "label": "Background gradient", "info": "Optional. Covers the background color." },
    { "type": "color", "id": "color_text", "label": "Text", "default": "#1F1B17" },
    { "type": "color", "id": "color_accent", "label": "Progress and icon", "default": "#4F6B3A" },
    { "type": "color", "id": "color_track", "label": "Progress track", "default": "#E2D9CC" },
    { "type": "color", "id": "color_card", "label": "Box background", "default": "#FFFFFF" },

    { "type": "header", "content": "Spacing and visibility" },
    { "type": "range", "id": "padding_top", "label": "Top padding on desktop", "min": 0, "max": 120, "step": 4, "unit": "px", "default": 16 },
    { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 120, "step": 4, "unit": "px", "default": 16 },
    { "type": "range", "id": "padding_top_mobile", "label": "Top padding on mobile", "min": 0, "max": 120, "step": 4, "unit": "px", "default": 12 },
    { "type": "range", "id": "padding_bottom_mobile", "label": "Bottom padding on mobile", "min": 0, "max": 120, "step": 4, "unit": "px", "default": 12 },
    {
      "type": "select",
      "id": "show_on",
      "label": "Show on",
      "options": [
        { "value": "all", "label": "All devices" },
        { "value": "desktop", "label": "Desktop only" },
        { "value": "mobile", "label": "Mobile only" }
      ],
      "default": "all"
    },
    { "type": "text", "id": "anchor_id", "label": "Anchor ID", "info": "Optional. Link to this section with #your-id." },

    { "type": "header", "content": "Motion" },
    { "type": "checkbox", "id": "animate", "label": "Animate the progress bar", "info": "Never when reduced motion is on.", "default": true }
  ],
  "blocks": [
    {
      "type": "currency",
      "name": "Currency threshold",
      "settings": [
        { "type": "paragraph", "content": "An exact threshold for shoppers who pay in another currency (Shopify Markets). Without one, the store's threshold is converted at Shopify's rate." },
        { "type": "text", "id": "currency", "label": "Currency code", "info": "Three letters, for example EUR or GBP.", "default": "EUR" },
        { "type": "number", "id": "amount", "label": "Free shipping from", "default": 70 }
      ]
    }
  ],
  "presets": [{ "name": "Lib · Free shipping bar" }]
}
{% endschema %}
```

## License

MIT License, © 2026 Lintel. Free to use, change and ship in your own store or in client work; keep the license line at the top of each file.
