{%- 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 = '' | append: remaining_money | append: '' assign threshold_html = '' | append: threshold_money | append: '' assign message = message | escape | replace: '{remaining}', remaining_html | replace: '{threshold}', threshold_html -%} {% 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 %}