WebberUI

Free Shipping Meter (React)

A multi-tier threshold meter (free shipping → gift → discount) for React: rolling 'NT$X more for free shipping' copy, a colour change across the whole bar and a one-shot micro-confetti burst when a tier is crossed, and reached tiers listed as chips — drop it into a cart header on its own.

A spend-threshold bar that lives outside the cart: hand it subtotal and tiers (free shipping → gift → discount, ascending by amount) and the headline reads "NT$X more for free shipping", with the difference updating through digit-by-digit rolling reels. The track is split into equal-width segments per tier, each threshold gets a small icon node — grey until reached, then filled with a bounce — and the fill advances on a spring. The moment a new tier is crossed the whole bar switches to that tier's colour, the node fires 10 tiny confetti pieces, and the chip below ticks. Once everything is unlocked the copy changes to "已解鎖全部優惠 (all rewards unlocked)". compact mode collapses it into a slim strip for the top of a cart or side panel.

Loading preview…
npx shadcn@latest add https://webberui.com/r/free-shipping-meter.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

640
990
<FreeShippingMeter />

Installation

npx shadcn@latest add https://webberui.com/r/free-shipping-meter.json

Or, once registries are configured in components.json, install it as @webberui/free-shipping-meter.

Usage

import { FreeShippingMeter } from "@/components/ui/free-shipping-meter";
import { Gift, Truck } from "lucide-react";

// Single tier: when there is only a free-shipping threshold, pass threshold directly
<FreeShippingMeter subtotal={640} threshold={990} />

// Multi-tier: free shipping → gift → discount (ascending by amount)
<FreeShippingMeter
  subtotal={cartSubtotal}
  currency="NT$"
  tiers={[
    { amount: 990, label: "免運", icon: Truck, reward: "全館免運" },
    { amount: 1500, label: "贈品", icon: Gift, reward: "送帆布購物袋" },
    { amount: 2500, label: "折扣", reward: "結帳再折 NT$100" },
  ]}
  onTierReached={(tier, index) => console.log(`Reached tier ${index + 1}: ${tier.label}`)}
/>

// Cart header: compact mode
<FreeShippingMeter subtotal={cartSubtotal} tiers={tiers} compact />

Props

PropTypeDefaultDescription
subtotalnumber0Current cart subtotal; supplied from outside, the component derives progress, copy, and reached state from it
tiersFreeShippingTier[]Threshold tiers (ascending by amount; the component re-sorts them and drops non-positive amounts); when omitted a single "免運 (free shipping)" tier is built from threshold
thresholdnumber990Single-tier shorthand: when there is only a free-shipping threshold, pass the amount directly — equivalent to tiers=[{ amount, label: "免運" }]; ignored when tiers is provided
currencystring"NT$"Currency prefix shown before every amount
onTierReached(tier: FreeShippingTier, index: number) => voidCalled the first time the subtotal crosses a tier; tiers already reached at mount do not fire, and jumping across several tiers at once fires once per tier in order
compactbooleanfalseCompact mode: thinner track, smaller text, and no subtotal readout, reward line, or chip row — made for a cart header
ariaLabelstring"免運門檻進度" (free-shipping progress)Accessible label of the progress bar
classNamestringForwarded to the outermost container

FreeShippingTier

FieldTypeDefaultDescription
amountnumberThreshold amount (a subtotal equal to or above it counts as reached)
labelstringTier name shown in the copy and on the chip, e.g. "免運" (free shipping), "贈品" (gift), "折扣" (discount)
iconLucideIconNode icon; when omitted the tiers use truck / gift / percent badge / sparkles in order
rewardstringReward description; shown under the next-tier copy and used as the chip's title

tierProgress(subtotal, tiers) (returns the 0–100 track percentage) and the FreeShippingTier type are also named exports, so the same progress can be drawn elsewhere.

How it works

  • Equal-width segments: the track percentage is not proportional to amount — every tier takes the same width and progress is interpolated linearly inside the segment. Threshold amounts are often far apart (990 / 1500 / 3000); a proportional bar squeezes the early tiers together and overlaps the nodes, while equal widths keep the layout stable and readable
  • Difference rounds up: "NT$X more" is computed with Math.ceil, so buying exactly what the copy says is guaranteed to cross the threshold; amounts are always shown as integers with thousands separators
  • Colour follows the highest reached tier: before any tier is reached the fill is a neutral dark; from the first tier onwards it steps through emerald → violet → amber → sky (cycling past four tiers), while chips and nodes keep their own tier's colour
  • Confetti only on a crossing: the reached count at mount is the baseline — tiers already reached at that point neither burst nor call onTierReached; particle trajectories come from a deterministic table rather than Math.random, so SSR and CSR output match
  • tiers are filtered of non-positive amounts and sorted by amount inside the component, so a wrong order does not break anything, but writing them ascending is still recommended so onTierReached's index lines up

Accessibility

  • The track uses role="progressbar" with aria-valuemin 0, aria-valuemax set to the highest tier amount, aria-valuenow set to the current subtotal, and aria-valuetext carrying the full sentence ("小計 NT$640,再買 NT$350 即可享免運" — subtotal NT$640, NT$350 more for free shipping)
  • The visual rolling reels contain every digit 0–9, so the whole visual copy is aria-hidden; the real message lives in a hidden role="status", aria-live="polite" paragraph and screen readers announce the latest difference whenever the subtotal changes
  • The chip row is a semantic ul, each chip carries hidden "(reached) / (not reached)" text, and each node's title gives the tier name and threshold amount
  • When the user has "reduce motion" enabled at the system level: no confetti, the fill and digits jump straight into place, and nodes and chips do not bounce — only the colour and text changes remain

On this page