← All sections

Free-Shipping Progress

A live free-shipping meter that turns the last few euros into one more item.

FreeBundles & upsell20 settingsInstall
Full screen ↗

Install

One file: sections/mf-free-shipping-bar.liquid. It appears in the theme editor as EX · Free shipping.

  1. Connect EXPERAISE Sections to Claude (once). In Claude Code:
    terminal
    claude mcp add --transport http experaise-sections https://sections.experaise.com/api/mcp
    In the Claude app: Settings → Connectors → Add custom connector → https://sections.experaise.com/api/mcp. Full setup
  2. Ask Claude. It fetches sections/mf-free-shipping-bar.liquid, pushes only that file to your theme with Shopify CLI, and tells you where to find it.
    prompt
    Install the EXPERAISE Sections section "Free-Shipping Progress" (free-shipping-bar) on my Shopify store your-store.myshopify.com. Put it on an unpublished copy of my live theme, then tell me how to add it in the theme editor.
  3. Customize. Online Store → Themes → Customize → Add section → EX · Free shipping. Every text, color and product is a setting.
sections/mf-free-shipping-bar.liquidDownload
{%- liquid
  assign s = section.settings
  assign threshold_units = s.threshold | default: 60
  assign threshold = threshold_units | times: 100 | round
  if threshold < 1
    assign threshold = 1
  endif

  assign total = cart.total_price | default: 0
  assign count = cart.item_count | default: 0
  assign remaining = threshold | minus: total
  if remaining < 0
    assign remaining = 0
  endif

  assign pct = total | times: 100 | divided_by: threshold
  if pct > 100
    assign pct = 100
  endif

  if count == 0
    assign state = 'empty'
    assign pct = 0
    assign message = s.empty_message
  elsif remaining == 0
    assign state = 'unlocked'
    assign message = s.unlocked_message
  else
    assign state = 'progress'
    assign message = s.message
  endif

  assign remaining_html = remaining | money | prepend: '<strong class="mf-ship__amount" data-mf-amount>' | append: '</strong>'
  assign threshold_html = threshold | money | prepend: '<strong class="mf-ship__amount">' | append: '</strong>'
  assign message_html = message | escape | replace: '[remaining]', remaining_html | replace: '[threshold]', threshold_html
-%}

{%- capture mf_icon -%}
  {%- case s.icon -%}
    {%- when 'gift' -%}
      <svg class="mf-ship__icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 9h16v3.5H4zM5.5 12.5V20h13v-7.5M12 9v11M12 9c-1.6-3.8-6-4.3-6-1.6C6 9 8.7 9 12 9zm0 0c1.6-3.8 6-4.3 6-1.6C18 9 15.3 9 12 9z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
    {%- when 'sparkle' -%}
      <svg class="mf-ship__icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M11 3c.7 5 2.6 7 7.5 7.7-4.9.7-6.8 2.7-7.5 7.7-.7-5-2.6-7-7.5-7.7C8.4 10 10.3 8 11 3z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M18.5 15.5c.3 1.9.9 2.5 2.5 2.8-1.6.3-2.2.9-2.5 2.7-.3-1.8-.9-2.4-2.5-2.7 1.6-.3 2.2-.9 2.5-2.8z" fill="currentColor"/></svg>
    {%- else -%}
      <svg class="mf-ship__icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M2.5 6.5h11v9.5h-11zM13.5 9.5h4.2l3.3 3.6V16h-7.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><circle cx="7" cy="17.3" r="1.9" fill="currentColor"/><circle cx="17" cy="17.3" r="1.9" fill="currentColor"/></svg>
  {%- endcase -%}
{%- endcapture -%}

<mf-free-shipping
  class="mf-ship mf-ship--{{ s.style }} mf-ship--{{ state }}{% unless s.show_panel %} mf-ship--flat{% endunless %}"
  data-threshold="{{ threshold }}"
  data-total="{{ total }}"
  data-count="{{ count }}"
  data-currency="{{ cart.currency.iso_code }}"
  data-locale="{{ request.locale.iso_code }}"
  data-msg-progress="{{ s.message | escape }}"
  data-msg-unlocked="{{ s.unlocked_message | escape }}"
  data-msg-empty="{{ s.empty_message | escape }}"
  style="--mf-ship-bg: {{ s.color_bg }}; --mf-ship-panel: {{ s.color_panel }}; --mf-ship-fg: {{ s.color_text }}; --mf-ship-track: {{ s.color_track }}; --mf-ship-f1: {{ s.color_fill_1 }}; --mf-ship-f2: {{ s.color_fill_2 }}; --mf-ship-n: {{ pct }}; --mf-ship-width: {{ s.max_width }}px; --mf-ship-pt: {{ s.padding_top }}px; --mf-ship-pb: {{ s.padding_bottom }}px;"
>
  <div class="mf-ship__inner">
    <div class="mf-ship__panel">
      {%- if s.style == 'ring' -%}
        <div
          class="mf-ship__ring"
          role="progressbar"
          aria-label="{{ s.progress_label | escape }}"
          aria-valuemin="0"
          aria-valuemax="100"
          aria-valuenow="{{ pct }}"
          aria-valuetext="{{ total | money }} / {{ threshold | money }}"
          data-mf-progress
        >
          <svg class="mf-ship__ring-svg" viewBox="0 0 64 64" aria-hidden="true">
            <defs>
              <linearGradient id="MfShipGrad-{{ section.id }}" x1="0" y1="0" x2="1" y2="1">
                <stop class="mf-ship__stop mf-ship__stop--a" offset="0"></stop>
                <stop class="mf-ship__stop mf-ship__stop--b" offset="1"></stop>
              </linearGradient>
            </defs>
            <circle class="mf-ship__ring-track" cx="32" cy="32" r="27" pathLength="100"></circle>
            <circle class="mf-ship__ring-fill" cx="32" cy="32" r="27" pathLength="100" stroke="url(#MfShipGrad-{{ section.id }})"></circle>
          </svg>
          <span class="mf-ship__ring-icon" aria-hidden="true">{{ mf_icon }}</span>
          <span class="mf-ship__burst" data-mf-burst aria-hidden="true"></span>
        </div>
      {%- endif -%}

      <div class="mf-ship__content">
        <div class="mf-ship__top">
          <p class="mf-ship__msg" data-mf-msg aria-live="polite">
            {%- if s.style == 'bar' -%}
              <span class="mf-ship__lead" aria-hidden="true">{{ mf_icon }}</span>
            {%- endif -%}
            <span class="mf-ship__text" data-mf-text>{{ message_html }}</span>
          </p>
          {%- if s.link_label != blank -%}
            <a class="mf-ship__link" href="{{ s.link_url | default: routes.all_products_collection_url }}">
              {{- s.link_label | escape -}}
              <svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M4 10h11m-4-5 5 5-5 5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
            </a>
          {%- endif -%}
        </div>

        {%- if s.style == 'ring' -%}
          <p class="mf-ship__stats">
            <span data-mf-total>{{ total | money }}</span>
            <span class="mf-ship__stats-sep" aria-hidden="true">/</span>
            {{ threshold | money }}
          </p>
        {%- else -%}
          <div class="mf-ship__meter">
            <div
              class="mf-ship__track"
              role="progressbar"
              aria-label="{{ s.progress_label | escape }}"
              aria-valuemin="0"
              aria-valuemax="100"
              aria-valuenow="{{ pct }}"
              aria-valuetext="{{ total | money }} / {{ threshold | money }}"
              data-mf-progress
            >
              <span class="mf-ship__rail" aria-hidden="true">
                <span class="mf-ship__fill"></span>
              </span>
              <span class="mf-ship__rider" aria-hidden="true">
                <span class="mf-ship__knob">{{ mf_icon }}</span>
                <span class="mf-ship__burst" data-mf-burst></span>
              </span>
            </div>
            {%- if s.show_goal -%}
              <span class="mf-ship__goal">
                <svg class="mf-ship__goal-check" viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
                {{- threshold | money_without_trailing_zeros -}}
              </span>
            {%- endif -%}
          </div>
        {%- endif -%}
      </div>
    </div>
  </div>
</mf-free-shipping>

{% stylesheet %}
  .mf-ship {
    --mf-ship-line: color-mix(in srgb, var(--mf-ship-fg) 11%, transparent);
    --mf-ship-soft: color-mix(in srgb, var(--mf-ship-fg) 68%, var(--mf-ship-panel));
    --mf-ship-ease: cubic-bezier(0.22, 0.8, 0.2, 1);
    --mf-ship-dur: 1.1s;
    --mf-ship-knob: 36px;
    position: relative;
    isolation: isolate;
    display: block;
    overflow: hidden;
    padding: var(--mf-ship-pt, 24px) 0 var(--mf-ship-pb, 24px);
    background:
      radial-gradient(360px 150px at 22% 60%, color-mix(in srgb, var(--mf-ship-f1) 20%, transparent), transparent 72%),
      radial-gradient(420px 170px at 80% 35%, color-mix(in srgb, var(--mf-ship-f2) 24%, transparent), transparent 72%),
      var(--mf-ship-bg, #0c0d11);
    color: var(--mf-ship-fg, #f4f1ea);
    font-family: inherit;
    font-size: 15px;
    line-height: 1.4;
  }
  .mf-ship *,
  .mf-ship *::before,
  .mf-ship *::after {
    box-sizing: border-box;
  }
  .mf-ship__inner {
    width: min(100% - 40px, var(--mf-ship-width, 900px));
    margin: 0 auto;
  }
  .mf-ship__panel {
    position: relative;
    display: flex;
    align-items: center;
    gap: 22px;
    padding: 18px 24px 20px;
    border: 1px solid var(--mf-ship-line);
    border-radius: 20px;
    background:
      linear-gradient(180deg, color-mix(in srgb, var(--mf-ship-fg) 4%, transparent), transparent 60%),
      var(--mf-ship-panel, #16181f);
    box-shadow: 0 24px 50px -34px rgba(0, 0, 0, 0.7), inset 0 1px 0 color-mix(in srgb, var(--mf-ship-fg) 7%, transparent);
    transition: box-shadow 0.6s ease, border-color 0.6s ease;
  }
  .mf-ship--unlocked .mf-ship__panel {
    border-color: color-mix(in srgb, var(--mf-ship-f1) 36%, transparent);
    box-shadow:
      0 24px 50px -30px color-mix(in srgb, var(--mf-ship-f1) 45%, transparent),
      inset 0 1px 0 color-mix(in srgb, var(--mf-ship-fg) 7%, transparent);
  }
  .mf-ship--flat .mf-ship__panel,
  .mf-ship--flat.mf-ship--unlocked .mf-ship__panel {
    padding: 0;
    border: 0;
    background: none;
    box-shadow: none;
  }
  .mf-ship__panel::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background: linear-gradient(105deg, transparent 35%, color-mix(in srgb, var(--mf-ship-fg) 9%, transparent) 50%, transparent 65%);
    opacity: 0;
    transform: translateX(-60%);
    pointer-events: none;
  }
  .mf-ship--pop .mf-ship__panel::after {
    animation: mf-ship-sweep 1.2s var(--mf-ship-ease);
  }
  .mf-ship__content {
    flex: 1;
    min-width: 0;
  }

  /* Message */
  .mf-ship__top {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 6px 18px;
  }
  .mf-ship__msg {
    display: flex;
    align-items: center;
    gap: 10px;
    min-width: 0;
    margin: 0;
    color: inherit;
    font-size: clamp(14px, 1.4vw, 16px);
    font-weight: 500;
    line-height: 1.4;
    letter-spacing: -0.005em;
    text-transform: none;
  }
  .mf-ship__lead {
    display: inline-grid;
    flex-shrink: 0;
    place-items: center;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    background: color-mix(in srgb, var(--mf-ship-fg) 7%, transparent);
    color: var(--mf-ship-fg);
    transition: background 0.5s ease;
  }
  .mf-ship__lead .mf-ship__icon {
    width: 16px;
    height: 16px;
  }
  .mf-ship--unlocked .mf-ship__lead {
    background: linear-gradient(135deg, color-mix(in srgb, var(--mf-ship-f1) 40%, transparent), color-mix(in srgb, var(--mf-ship-f2) 40%, transparent));
  }
  .mf-ship__text {
    min-width: 0;
  }
  .mf-ship__text--in {
    animation: mf-ship-text-in 0.5s var(--mf-ship-ease);
  }
  .mf-ship__amount {
    display: inline-block;
    padding: 0 1px;
    color: inherit;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    background: linear-gradient(90deg, var(--mf-ship-f1), var(--mf-ship-f2)) no-repeat 0 100% / 100% 2px;
  }
  .mf-ship__link {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    gap: 6px;
    min-height: 40px;
    color: var(--mf-ship-soft);
    font-size: 13px;
    font-weight: 600;
    line-height: 1;
    letter-spacing: 0;
    text-decoration: none;
    transition: color 0.2s ease;
  }
  .mf-ship__link svg {
    transition: transform 0.25s ease;
  }
  .mf-ship__link:hover {
    color: var(--mf-ship-fg);
  }
  .mf-ship__link:hover svg {
    transform: translateX(3px);
  }
  .mf-ship__link:focus-visible {
    outline: 2px solid var(--mf-ship-f2);
    outline-offset: 3px;
    border-radius: 4px;
  }

  /* Bar */
  .mf-ship__meter {
    display: flex;
    align-items: center;
    gap: 14px;
    margin-top: 10px;
  }
  .mf-ship__track {
    position: relative;
    display: flex;
    flex: 1;
    align-items: center;
    min-width: 0;
    height: var(--mf-ship-knob);
  }
  .mf-ship__rail {
    position: relative;
    display: block;
    width: 100%;
    height: 10px;
    overflow: hidden;
    border-radius: 999px;
    background: var(--mf-ship-track, #2a2d37);
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.25);
  }
  .mf-ship__fill {
    position: absolute;
    inset: 0;
    display: block;
    border-radius: inherit;
    background: linear-gradient(90deg, var(--mf-ship-f1), var(--mf-ship-f2));
    transform: translateX(calc(var(--mf-ship-n, 0) * 1% - 100%));
    transition: transform var(--mf-ship-dur) var(--mf-ship-ease);
  }
  .mf-ship__fill::before {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    width: 36px;
    border-radius: inherit;
    background: radial-gradient(circle at 100% 50%, rgba(255, 255, 255, 0.85), transparent 70%);
    opacity: 0.7;
  }
  .mf-ship__fill::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 30%;
    background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
    transform: translateX(-120%);
    animation: mf-ship-shimmer 2.4s ease-in-out infinite;
  }
  .mf-ship__rider {
    position: absolute;
    top: 0;
    bottom: 0;
    left: calc(var(--mf-ship-knob) / 2);
    right: calc(var(--mf-ship-knob) / 2);
    display: block;
    pointer-events: none;
    transform: translateX(calc(var(--mf-ship-n, 0) * 1%));
    transition: transform var(--mf-ship-dur) var(--mf-ship-ease);
  }
  .mf-ship__knob {
    position: absolute;
    top: 50%;
    left: 0;
    display: grid;
    place-items: center;
    width: var(--mf-ship-knob);
    height: var(--mf-ship-knob);
    margin: calc(var(--mf-ship-knob) / -2) 0 0 calc(var(--mf-ship-knob) / -2);
    border: 2px solid transparent;
    border-radius: 50%;
    background:
      linear-gradient(var(--mf-ship-panel), var(--mf-ship-panel)) padding-box,
      linear-gradient(135deg, var(--mf-ship-f1), var(--mf-ship-f2)) border-box;
    color: var(--mf-ship-fg);
    box-shadow:
      0 0 0 5px color-mix(in srgb, var(--mf-ship-f2) 16%, transparent),
      0 8px 22px -6px color-mix(in srgb, var(--mf-ship-f2) 60%, transparent);
  }
  .mf-ship__knob .mf-ship__icon {
    width: 17px;
    height: 17px;
  }
  .mf-ship--progress .mf-ship__knob .mf-ship__icon {
    animation: mf-ship-bob 1.6s ease-in-out infinite;
  }
  .mf-ship--empty .mf-ship__knob {
    box-shadow: 0 0 0 5px color-mix(in srgb, var(--mf-ship-fg) 6%, transparent);
  }
  .mf-ship--pop .mf-ship__knob,
  .mf-ship--pop .mf-ship__ring-icon {
    animation: mf-ship-knob-pop 0.7s cubic-bezier(0.3, 1.6, 0.5, 1);
  }
  .mf-ship__goal {
    display: inline-flex;
    flex-shrink: 0;
    align-items: center;
    gap: 5px;
    min-height: 30px;
    padding: 0 12px;
    border: 1.5px solid var(--mf-ship-line);
    border-radius: 999px;
    color: var(--mf-ship-soft);
    font-size: 13px;
    font-weight: 700;
    line-height: 1;
    font-variant-numeric: tabular-nums;
    transition: color 0.4s ease, border-color 0.4s ease, background 0.4s ease;
  }
  .mf-ship__goal-check {
    display: none;
  }
  .mf-ship--unlocked .mf-ship__goal {
    border-color: transparent;
    background:
      linear-gradient(var(--mf-ship-panel), var(--mf-ship-panel)) padding-box,
      linear-gradient(90deg, var(--mf-ship-f1), var(--mf-ship-f2)) border-box;
    color: var(--mf-ship-fg);
  }
  .mf-ship--unlocked .mf-ship__goal-check {
    display: block;
    color: var(--mf-ship-f1);
  }

  /* Ring */
  .mf-ship__ring {
    position: relative;
    flex-shrink: 0;
    width: 68px;
    height: 68px;
  }
  .mf-ship__ring-svg {
    display: block;
    width: 100%;
    height: 100%;
    overflow: visible;
    transform: rotate(-90deg);
  }
  .mf-ship__stop--a {
    stop-color: var(--mf-ship-f1);
  }
  .mf-ship__stop--b {
    stop-color: var(--mf-ship-f2);
  }
  .mf-ship__ring-track,
  .mf-ship__ring-fill {
    fill: none;
    stroke-width: 5;
  }
  .mf-ship__ring-track {
    stroke: var(--mf-ship-track);
  }
  .mf-ship__ring-fill {
    stroke-linecap: round;
    stroke-dasharray: 100 100;
    stroke-dashoffset: calc(100 - var(--mf-ship-n, 0));
    filter: drop-shadow(0 0 4px color-mix(in srgb, var(--mf-ship-f2) 55%, transparent));
    transition: stroke-dashoffset var(--mf-ship-dur) var(--mf-ship-ease), opacity 0.3s ease;
  }
  .mf-ship--empty .mf-ship__ring-fill {
    opacity: 0;
  }
  .mf-ship__ring-icon {
    position: absolute;
    inset: 13px;
    display: grid;
    place-items: center;
    border-radius: 50%;
    background: color-mix(in srgb, var(--mf-ship-fg) 6%, transparent);
    color: var(--mf-ship-fg);
  }
  .mf-ship--unlocked .mf-ship__ring-icon {
    background: linear-gradient(135deg, color-mix(in srgb, var(--mf-ship-f1) 38%, transparent), color-mix(in srgb, var(--mf-ship-f2) 38%, transparent));
  }
  .mf-ship__ring .mf-ship__burst {
    top: 50%;
    left: 50%;
  }
  .mf-ship__stats {
    display: flex;
    align-items: baseline;
    gap: 6px;
    margin: 6px 0 0;
    color: var(--mf-ship-soft);
    font-size: 13px;
    font-weight: 500;
    line-height: 1.3;
    font-variant-numeric: tabular-nums;
  }
  .mf-ship__stats [data-mf-total] {
    color: var(--mf-ship-fg);
    font-weight: 700;
  }
  .mf-ship__stats-sep {
    opacity: 0.5;
  }

  /* Celebration */
  .mf-ship__burst {
    position: absolute;
    z-index: 3;
    top: 50%;
    left: 0;
    width: 0;
    height: 0;
    pointer-events: none;
  }
  .mf-ship__particle {
    position: absolute;
    top: 0;
    left: 0;
    width: 7px;
    height: 7px;
    border-radius: 2px;
    background: var(--mf-ship-f1);
    opacity: 0;
    animation: mf-ship-burst 0.95s cubic-bezier(0.15, 0.7, 0.3, 1) forwards;
  }
  .mf-ship__particle--1 {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--mf-ship-f2);
  }
  .mf-ship__particle--2 {
    width: 4px;
    height: 9px;
    background: var(--mf-ship-fg);
  }
  .mf-ship--instant .mf-ship__fill,
  .mf-ship--instant .mf-ship__rider,
  .mf-ship--instant .mf-ship__ring-fill {
    transition: none;
  }

  @keyframes mf-ship-shimmer {
    0% {
      transform: translateX(-120%);
    }
    60%,
    100% {
      transform: translateX(380%);
    }
  }
  @keyframes mf-ship-bob {
    0%,
    100% {
      transform: translateY(0);
    }
    50% {
      transform: translateY(-1.5px);
    }
  }
  @keyframes mf-ship-knob-pop {
    0% {
      transform: scale(1);
    }
    35% {
      transform: scale(1.28);
    }
    100% {
      transform: scale(1);
    }
  }
  @keyframes mf-ship-burst {
    0% {
      opacity: 1;
      transform: translate(-50%, -50%) scale(0.3) rotate(0deg);
    }
    70% {
      opacity: 1;
    }
    100% {
      opacity: 0;
      transform: translate(calc(-50% + var(--mf-ship-dx, 0px)), calc(-50% + var(--mf-ship-dy, 0px))) scale(1) rotate(var(--mf-ship-rot, 90deg));
    }
  }
  @keyframes mf-ship-sweep {
    0% {
      opacity: 0;
      transform: translateX(-60%);
    }
    30% {
      opacity: 1;
    }
    100% {
      opacity: 0;
      transform: translateX(60%);
    }
  }
  @keyframes mf-ship-text-in {
    0% {
      opacity: 0;
      transform: translateY(6px);
    }
    100% {
      opacity: 1;
      transform: none;
    }
  }

  @media screen and (max-width: 749px) {
    .mf-ship {
      --mf-ship-knob: 32px;
    }
    .mf-ship__inner {
      width: min(100% - 32px, var(--mf-ship-width, 900px));
    }
    .mf-ship__panel {
      gap: 14px;
      padding: 14px 16px 16px;
      border-radius: 18px;
    }
    .mf-ship__msg {
      align-items: flex-start;
      font-size: 14px;
    }
    .mf-ship__lead {
      width: 26px;
      height: 26px;
      margin-top: -2px;
    }
    .mf-ship__content {
      display: flex;
      flex-direction: column;
    }
    .mf-ship__top {
      display: contents;
    }
    .mf-ship__msg {
      order: 1;
    }
    .mf-ship__meter,
    .mf-ship__stats {
      order: 2;
    }
    .mf-ship__link {
      order: 3;
      align-self: flex-start;
      min-height: 32px;
      margin: 6px 0 -8px;
    }
    .mf-ship__meter {
      gap: 10px;
      margin-top: 8px;
    }
    .mf-ship__goal {
      min-height: 28px;
      padding: 0 10px;
      font-size: 12px;
    }
    .mf-ship__ring {
      width: 58px;
      height: 58px;
    }
    .mf-ship__ring-icon {
      inset: 11px;
    }
  }

  @media (prefers-reduced-motion: reduce) {
    .mf-ship__fill,
    .mf-ship__rider,
    .mf-ship__ring-fill,
    .mf-ship__panel,
    .mf-ship__goal {
      transition: none;
    }
    .mf-ship__fill::after,
    .mf-ship--progress .mf-ship__knob .mf-ship__icon,
    .mf-ship--pop .mf-ship__knob,
    .mf-ship--pop .mf-ship__ring-icon,
    .mf-ship--pop .mf-ship__panel::after,
    .mf-ship__text--in,
    .mf-ship__particle {
      animation: none;
    }
    .mf-ship__fill::after,
    .mf-ship__particle {
      display: none;
    }
  }
{% endstylesheet %}

{% javascript %}
  if (!customElements.get('mf-free-shipping')) {
    customElements.define(
      'mf-free-shipping',
      class extends HTMLElement {
        connectedCallback() {
          this.reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
          this.text = this.querySelector('[data-mf-text]');
          this.progress = this.querySelector('[data-mf-progress]');
          this.burst = this.querySelector('[data-mf-burst]');
          this.totalNode = this.querySelector('[data-mf-total]');
          this.timers = [];
          const rate = Number(window.Shopify && window.Shopify.currency && window.Shopify.currency.rate) || 1;
          const firstRun = !this.initialized;
          if (firstRun) {
            // Keep the last rendered cart state when the element is moved (reconnected).
            this.initialized = true;
            this.threshold = Math.max(1, Math.round((Number(this.dataset.threshold) || 1) * rate));
            this.total = Number(this.dataset.total) || 0;
            this.count = Number(this.dataset.count) || 0;
            this.remaining = Math.max(0, this.threshold - this.total);
            this.state = this.stateFor(this.total, this.count);
          }

          this.onCart = this.onCart.bind(this);
          this.onVisibility = this.onVisibility.bind(this);
          this.events = ['cart:refresh', 'cart:updated', 'mf:cart:added'];

          if (window.__MF_PREVIEW__) {
            this.demo();
            return;
          }
          this.events.forEach((name) => document.addEventListener(name, this.onCart));
          document.addEventListener('visibilitychange', this.onVisibility);
          if (firstRun && rate !== 1) this.render(this.total, this.count, false);
        }

        disconnectedCallback() {
          this.events.forEach((name) => document.removeEventListener(name, this.onCart));
          document.removeEventListener('visibilitychange', this.onVisibility);
          this.timers.forEach((t) => clearTimeout(t));
          this.timers = [];
          clearTimeout(this.debounce);
          if (this.raf) cancelAnimationFrame(this.raf);
        }

        onCart() {
          clearTimeout(this.debounce);
          this.debounce = setTimeout(() => this.refresh(), 120);
        }

        onVisibility() {
          if (document.visibilityState === 'visible') this.refresh();
        }

        refresh() {
          const root = (window.Shopify && window.Shopify.routes && window.Shopify.routes.root) || '/';
          fetch(root + 'cart.js', { headers: { Accept: 'application/json' } })
            .then((response) => (response.ok ? response.json() : Promise.reject(new Error('cart'))))
            .then((cart) => {
              if (cart.currency) this.dataset.currency = cart.currency;
              this.render(Number(cart.total_price) || 0, Number(cart.item_count) || 0, true);
            })
            .catch(() => {});
        }

        stateFor(total, count) {
          if (!count) return 'empty';
          return total >= this.threshold ? 'unlocked' : 'progress';
        }

        money(cents) {
          try {
            return new Intl.NumberFormat(this.dataset.locale || undefined, {
              style: 'currency',
              currency: this.dataset.currency || 'USD',
            }).format(cents / 100);
          } catch (e) {
            return (cents / 100).toFixed(2);
          }
        }

        message(state, remaining) {
          if (!this.text) return;
          const template =
            state === 'empty' ? this.dataset.msgEmpty : state === 'unlocked' ? this.dataset.msgUnlocked : this.dataset.msgProgress;
          const frag = document.createDocumentFragment();
          this.amountNode = null;
          String(template || '')
            .split(/(\[remaining\]|\[threshold\])/)
            .forEach((part) => {
              if (part === '[remaining]' || part === '[threshold]') {
                const strong = document.createElement('strong');
                strong.className = 'mf-ship__amount';
                strong.textContent = this.money(part === '[remaining]' ? remaining : this.threshold);
                if (part === '[remaining]') this.amountNode = strong;
                frag.appendChild(strong);
              } else if (part) {
                frag.appendChild(document.createTextNode(part));
              }
            });
          this.text.textContent = '';
          this.text.appendChild(frag);
        }

        countTo(from, to) {
          if (this.raf) cancelAnimationFrame(this.raf);
          const node = this.amountNode;
          if (!node) return;
          if (this.reduce || from === to) {
            node.textContent = this.money(to);
            return;
          }
          const duration = 800;
          let start = null;
          const step = (now) => {
            if (start === null) start = now;
            const t = Math.max(0, Math.min(1, (now - start) / duration));
            const eased = 1 - Math.pow(1 - t, 3);
            node.textContent = this.money(Math.round(from + (to - from) * eased));
            if (t < 1) this.raf = requestAnimationFrame(step);
          };
          node.textContent = this.money(from);
          this.raf = requestAnimationFrame(step);
        }

        render(total, count, animate) {
          const motion = animate && !this.reduce;
          const previousState = this.state;
          const previousRemaining = this.remaining;
          const state = this.stateFor(total, count);
          const remaining = Math.max(0, this.threshold - total);
          const pct = state === 'empty' ? 0 : Math.min(100, (total / this.threshold) * 100);
          this.total = total;
          this.count = count;
          this.state = state;
          this.remaining = remaining;

          if (!motion) this.classList.add('mf-ship--instant');
          this.style.setProperty('--mf-ship-n', pct.toFixed(2));
          this.classList.remove('mf-ship--empty', 'mf-ship--progress', 'mf-ship--unlocked');
          this.classList.add('mf-ship--' + state);
          if (!motion) {
            void this.offsetWidth;
            requestAnimationFrame(() => this.classList.remove('mf-ship--instant'));
          }

          if (state !== previousState || !motion) {
            this.message(state, remaining);
            if (motion && this.text) {
              this.text.classList.remove('mf-ship__text--in');
              void this.text.offsetWidth;
              this.text.classList.add('mf-ship__text--in');
            }
          } else if (state === 'progress') {
            if (!this.amountNode) this.message(state, remaining);
            this.countTo(previousRemaining, remaining);
          }

          if (this.progress) {
            this.progress.setAttribute('aria-valuenow', String(Math.round(pct)));
            this.progress.setAttribute('aria-valuetext', this.money(total) + ' / ' + this.money(this.threshold));
          }
          if (this.totalNode) this.totalNode.textContent = this.money(total);

          if (state === 'unlocked' && previousState !== 'unlocked' && motion) {
            const mover = this.querySelector('.mf-ship__rider, .mf-ship__ring-fill');
            let fired = false;
            const go = () => {
              if (fired) return;
              fired = true;
              if (mover) mover.removeEventListener('transitionend', go);
              this.celebrate();
            };
            if (mover) mover.addEventListener('transitionend', go);
            this.timers.push(setTimeout(go, 1250));
          }
        }

        celebrate() {
          if (this.reduce || !this.burst) return;
          this.burst.textContent = '';
          const total = 24;
          for (let i = 0; i < total; i += 1) {
            const particle = document.createElement('i');
            particle.className = 'mf-ship__particle mf-ship__particle--' + (i % 3);
            const angle = (Math.PI * 2 * i) / total + Math.random() * 0.35;
            const distance = 26 + Math.random() * 42;
            particle.style.setProperty('--mf-ship-dx', (Math.cos(angle) * distance).toFixed(1) + 'px');
            particle.style.setProperty('--mf-ship-dy', (Math.sin(angle) * distance * 0.75).toFixed(1) + 'px');
            particle.style.setProperty('--mf-ship-rot', Math.round(Math.random() * 540 - 270) + 'deg');
            particle.style.animationDelay = Math.round(Math.random() * 90) + 'ms';
            this.burst.appendChild(particle);
          }
          this.classList.remove('mf-ship--pop');
          void this.offsetWidth;
          this.classList.add('mf-ship--pop');
          this.timers.push(
            setTimeout(() => {
              if (this.burst) this.burst.textContent = '';
              this.classList.remove('mf-ship--pop');
            }, 1400)
          );
        }

        demo() {
          const threshold = this.threshold;
          const steps = [
            Math.round(threshold * 0.5333),
            Math.round(threshold * 0.7833),
            threshold,
          ];
          let index = 0;
          const tick = () => {
            if (index < steps.length) {
              this.render(steps[index], 2, true);
              index += 1;
              this.timers.push(setTimeout(tick, 2500));
            } else {
              index = 0;
              this.render(0, 2, false);
              this.timers.push(setTimeout(tick, 350));
            }
          };
          this.render(0, 2, false);
          this.timers.push(setTimeout(tick, 250));
        }
      }
    );
  }
{% endjavascript %}

{% schema %}
{
  "name": "EX · Free shipping",
  "tag": "section",
  "class": "mf-section",
  "disabled_on": {
    "groups": ["footer"]
  },
  "settings": [
    {
      "type": "header",
      "content": "Goal"
    },
    {
      "type": "number",
      "id": "threshold",
      "label": "Free-shipping threshold",
      "info": "In your store's main currency, e.g. 60. Converted with the market exchange rate for other currencies. Match it to your shipping rates in Settings → Shipping and delivery.",
      "default": 60
    },
    {
      "type": "text",
      "id": "message",
      "label": "Progress message",
      "info": "Use [remaining] for the amount left and [threshold] for the goal.",
      "default": "You're [remaining] away from free shipping"
    },
    {
      "type": "text",
      "id": "unlocked_message",
      "label": "Unlocked message",
      "default": "You've unlocked free shipping. Enjoy!"
    },
    {
      "type": "text",
      "id": "empty_message",
      "label": "Empty cart message",
      "default": "Free shipping on every order over [threshold]"
    },
    {
      "type": "text",
      "id": "progress_label",
      "label": "Progress label for screen readers",
      "default": "Free-shipping progress"
    },
    {
      "type": "text",
      "id": "link_label",
      "label": "Link label",
      "default": "Shop add-ons"
    },
    {
      "type": "url",
      "id": "link_url",
      "label": "Link",
      "default": "/collections/all"
    },
    {
      "type": "header",
      "content": "Style"
    },
    {
      "type": "select",
      "id": "style",
      "label": "Progress style",
      "options": [
        { "value": "bar", "label": "Bar" },
        { "value": "ring", "label": "Ring" }
      ],
      "default": "bar"
    },
    {
      "type": "select",
      "id": "icon",
      "label": "Icon",
      "options": [
        { "value": "truck", "label": "Truck" },
        { "value": "gift", "label": "Gift" },
        { "value": "sparkle", "label": "Sparkle" }
      ],
      "default": "truck"
    },
    {
      "type": "checkbox",
      "id": "show_goal",
      "label": "Show goal amount at the end of the bar",
      "default": true
    },
    {
      "type": "checkbox",
      "id": "show_panel",
      "label": "Show card background",
      "default": true
    },
    {
      "type": "header",
      "content": "Layout"
    },
    {
      "type": "range",
      "id": "max_width",
      "label": "Maximum width",
      "min": 480,
      "max": 1320,
      "step": 20,
      "unit": "px",
      "default": 880
    },
    {
      "type": "range",
      "id": "padding_top",
      "label": "Top padding",
      "min": 0,
      "max": 80,
      "step": 4,
      "unit": "px",
      "default": 28
    },
    {
      "type": "range",
      "id": "padding_bottom",
      "label": "Bottom padding",
      "min": 0,
      "max": 80,
      "step": 4,
      "unit": "px",
      "default": 28
    },
    {
      "type": "header",
      "content": "Colors"
    },
    {
      "type": "color",
      "id": "color_bg",
      "label": "Background",
      "default": "#0c0d11"
    },
    {
      "type": "color",
      "id": "color_panel",
      "label": "Card",
      "default": "#16181f"
    },
    {
      "type": "color",
      "id": "color_text",
      "label": "Text",
      "default": "#f4f1ea"
    },
    {
      "type": "color",
      "id": "color_track",
      "label": "Empty track",
      "default": "#2a2d37"
    },
    {
      "type": "color",
      "id": "color_fill_1",
      "label": "Progress gradient start",
      "default": "#7ef0c6"
    },
    {
      "type": "color",
      "id": "color_fill_2",
      "label": "Progress gradient end",
      "default": "#b69cff"
    }
  ],
  "presets": [
    {
      "name": "EX · Free shipping"
    }
  ]
}
{% endschema %}
Why it converts

A visible goal just out of reach makes shoppers add one more item to earn free shipping, lifting average order value.

Specs

File: sections/mf-free-shipping-bar.liquidIn the theme editor: EX · Free shippingBest on: cart, product, index, page templatesSettings: 20Validated: Shopify Theme Check, no external assets

About this section

Free-Shipping Progress is a free Shopify Online Store 2.0 section that shows how far the cart is from free shipping. Place it at the top of the cart page, on product pages or directly under the header. Liquid computes the first state on the server from cart.total_price and the threshold, so the message is correct before any JavaScript runs. Merchants set the threshold in the store's main currency, a progress message with a [remaining] placeholder, an unlocked message, an empty-cart message with a [threshold] placeholder, an optional link, the style (a slim bar or a compact ring), the icon (truck, gift or sparkle), whether to show the goal amount and a card background, the maximum width, padding and six colors. A small custom element listens for cart:refresh, cart:updated and mf:cart:added and for the tab becoming visible again, fetches the storefront's cart.js and updates the meter. The gradient fill grows with a shimmering leading edge while the icon rides the edge in a glowing knob; the remaining amount counts down, and crossing the threshold triggers a particle burst and lights up the goal chip. Amounts are formatted with Intl.NumberFormat in the cart currency and locale, and the threshold follows the market exchange rate. The conversion principle is goal gradient: people accelerate as a reward gets closer, so a visible, nearly reached goal nudges one more item into the cart. The bar is a labelled progressbar, the message is a polite live region, and reduced motion removes the fill animation, shimmer and particles. It is one self-contained .liquid file.

Questions

Does it update when a product is added to the cart?

Yes. It re-reads cart.js whenever the page dispatches cart:refresh, cart:updated or mf:cart:added, and when the shopper returns to the tab. EXPERAISE add-to-cart sections dispatch these events; if your theme's drawer uses another event, the meter still corrects itself on the next page view because the first state is rendered in Liquid.

Does this section give free shipping?

No, it only displays progress. Set the matching free-shipping rate in Settings → Shipping and delivery (a rate with a minimum order price), or a free-shipping discount, and enter the same amount in the Free-shipping threshold setting.

What about other currencies?

Enter the threshold in your store's main currency. When Shopify Markets shows prices in another currency, the section multiplies the threshold by the market exchange rate and formats amounts in the shopper's currency and language.

Can I put it under the header?

Yes. It can be added to the header group or any template. Use a small padding and switch off the card background for a slim, full-width strip, or keep the card on the cart page.

More sections