Blog · · 7 min
Free shipping threshold bar
Set the free shipping threshold against your margin, not because the number is round, and show the remainder in money: \"$6.00 to unlock free shipping\" is an instruction, a percentage is a conversion the buyer has to run. Never render the bar in an empty cart.
By uxgen
The bar says one thing: the amount still missing, in currency. $6.00 to unlock free shipping. Not 78%, not a filled rectangle with no figure attached, not You are close!. The buyer is about to decide whether to add something, and the only input that decision needs is a number they can compare against a price on the page.
Everything else about the component follows from that sentence. The bar is the illustration; the sentence is the mechanic.
Why the percentage fails
A percentage is a second arithmetic step. To act on 78% the buyer has to know the threshold, subtract, and convert the result into an item they might add. Three operations, at the exact moment you wanted them to do one.
The remaining amount skips all of it. $6.00 sits next to a $7.00 scoop on the same screen and the comparison is done before the buyer notices they made it.
The same applies to the bar without a number. A progress track alone tells you that something is incomplete and nothing about what completes it. Keep the track if it helps the eye, but treat it as ornament: the sentence carries the whole message, and the sentence is what a screen reader announces.
Where do you set the threshold?
Not on a round number. The threshold is an arithmetic result: it is the point at which the extra spend generates enough gross margin to pay for the shipping you are giving away.
If S is the shipping cost you absorb and m your gross margin rate, the extra spend E has to satisfy m × E ≥ S. So E = S / m, and the threshold sits at your median order plus that.
// Not a round number you liked. A number your margin can pay for.
const thresholdCents = Math.round(
(medianOrderCents + shippingCostCents / marginRate) / 500,
) * 500 // rounded to the nearest 5 units, upward-friendly
Worked through, on four shops with different economics:
| Median order | Shipping you absorb | Gross margin | Extra spend needed | Threshold to set |
|---|---|---|---|---|
| $22.00 | $5.00 | 60% | $8.33 | $30.00 |
| $38.00 | $6.00 | 55% | $10.90 | $50.00 |
| $38.00 | $6.00 | 30% | $20.00 | $60.00 |
| $70.00 | $9.00 | 40% | $22.50 | $95.00 |
Read the second and third rows together. Same shop size, same shipping cost, and the threshold differs by ten dollars purely because the margin does. A threshold copied from a competitor is a threshold copied from someone else's margin.
Two consequences worth naming. A threshold far above the median order is a bar almost nobody can reach, and it converts into a reminder that shipping is expensive here. A threshold below the median is a discount you are handing to orders that would have happened anyway.
When should the bar not appear at all?
Three cases, and they are the part every implementation skips.
The empty cart. A bar reading $50.00 to unlock free shipping before a single item is chosen is a price objection raised by the shop itself, in advance, unprompted.
Far below the threshold. At roughly 4% of the goal the bar is not encouragement, it is distance. The reference implementation takes a seuilAffichage prop defaulting to 0.4 and renders nothing below it. Pick your own share, but pick one.
After the goal is reached. Say Free shipping unlocked. plainly and stop animating. A bar that keeps pulsing past the finish line is noise on every subsequent render, and it competes with the checkout button for the same glance.
What the component actually needs
<FreeShippingBar
subtotalCents={4400}
thresholdCents={5000}
currency="USD"
/>
// → "$6.00 to unlock free shipping"
Three props. There is no message prop, no showPercentage, no confetti flag. The full file is in the commerce kit, MIT, one dependency, React.
Two details in it that are easy to lose:
- Format with
Intl.NumberFormat, in the buyer's locale. A hardcoded$and a dot decimal separator is wrong for most of Europe, and a shipping threshold is exactly the kind of figure a buyer reads as a number rather than as text. font-variant-numeric: tabular-numson the amount. The figure changes as the cart changes. Proportional digits make it jump sideways, which is the visual signature of a cheap widget.
Accessibility is one line of thought: the sentence is aria-live="polite" so a change is announced once, and the track carries role="progressbar" with an aria-valuetext that repeats the remaining amount in words. The track is not the message and must never be the only carrier of it.
Where it sits among the other three
The threshold bar is the second of four basket mechanics, and the order matters because each one acts on a different share of orders.
| Mechanic | Lives | Acts on |
|---|---|---|
| Quantity breaks | product page | every order, before a cart exists |
| Free-shipping bar | cart | orders already above ~40% of the threshold |
| Order bump | cart, above the totals | the confirmation moment |
| Sticky add-to-cart | mobile product page | every mobile session |
The bar is downstream of the tier selector, and that is the argument for building the tier selector first. A buyer who picked the three-unit tier arrives at the cart already past the threshold, and the bar simply congratulates them. A buyer who picked one unit arrives $6.00 short, which is where a named complement below the cart contents does its work. That is a different component with a legal constraint attached, and it is covered in where to place an upsell on a product page.
One thing the bar must not become: a countdown. Free shipping ends in 09:58 on a threshold that does not expire is a fabricated deadline, and in the EU an invented urgency claim falls under the misleading practices rules. If the offer really ends, say when. If it does not, say nothing about time.
What you will find if you search for this
Mostly Shopify app listings and theme snippets, and most of the snippets render a percentage. The pattern has no canonical spec on the open web, which is why a coding agent asked for a "free shipping progress bar" reliably produces a <progress> element with a computed ratio and no currency anywhere in the output. We wrote the same finding up for the tier selector in quantity breaks that raise average order value: the model is not guessing badly, it is reproducing the only thing it has seen a thousand times.
The inventory of what does and does not ship in the general component registries is in shadcn ecommerce blocks. No registry we checked carries this one.
FAQ
What should a free shipping progress bar say?
The remaining amount in currency, as a sentence: $6.00 to unlock free shipping. Not a percentage, not a bar with no number, not a vague encouragement. The buyer needs one figure they can compare against the price of something on the page.
How do I choose the free shipping threshold?
Take the shipping cost you absorb, divide it by your gross margin rate, and add the result to your median order value. That is the extra spend that pays for the shipping you are giving away. Rounding up to a clean figure afterwards is fine; starting from a clean figure is not.
Should the bar show in an empty cart?
No. A threshold announced before anything is chosen is a price objection you raised yourself. Below roughly 40% of the threshold the bar reads as distance rather than as a nudge, so render nothing at all until the cart is close enough for the number to be actionable.
Can I put a countdown on free shipping?
Only if the offer genuinely ends at that moment and you enforce it. A timer that resets on reload is a fabricated urgency claim, and in the EU it falls under the unfair commercial practices rules rather than under growth tactics.