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.
npx shadcn@latest add https://webberui.com/r/free-shipping-meter.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<FreeShippingMeter />
Installation
npx shadcn@latest add https://webberui.com/r/free-shipping-meter.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
subtotal | number | 0 | Current cart subtotal; supplied from outside, the component derives progress, copy, and reached state from it |
tiers | FreeShippingTier[] | — | 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 |
threshold | number | 990 | Single-tier shorthand: when there is only a free-shipping threshold, pass the amount directly — equivalent to tiers=[{ amount, label: "免運" }]; ignored when tiers is provided |
currency | string | "NT$" | Currency prefix shown before every amount |
onTierReached | (tier: FreeShippingTier, index: number) => void | — | Called 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 |
compact | boolean | false | Compact mode: thinner track, smaller text, and no subtotal readout, reward line, or chip row — made for a cart header |
ariaLabel | string | "免運門檻進度" (free-shipping progress) | Accessible label of the progress bar |
className | string | — | Forwarded to the outermost container |
FreeShippingTier
| Field | Type | Default | Description |
|---|---|---|---|
amount | number | — | Threshold amount (a subtotal equal to or above it counts as reached) |
label | string | — | Tier name shown in the copy and on the chip, e.g. "免運" (free shipping), "贈品" (gift), "折扣" (discount) |
icon | LucideIcon | — | Node icon; when omitted the tiers use truck / gift / percent badge / sparkles in order |
reward | string | — | Reward 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 thanMath.random, so SSR and CSR output match tiersare filtered of non-positive amounts and sorted byamountinside the component, so a wrong order does not break anything, but writing them ascending is still recommended soonTierReached'sindexlines up
Accessibility
- The track uses
role="progressbar"witharia-valuemin0,aria-valuemaxset to the highest tier amount,aria-valuenowset to the current subtotal, andaria-valuetextcarrying 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 hiddenrole="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'stitlegives 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
Installment Calculator (React)
An inline installment calculator React component for product pages: 3/6/12/24-month segmented switch, annual rate and fees, a rolling monthly amount, total and interest, effective annual rate, a 0% badge and an expandable per-period schedule.
Live Shopping Overlay (React)
A React live-selling UI overlay — on a fixed-ratio stage it layers a LIVE badge with a rolling viewer count, heart bursts, a scrolling comment stream, automatic "+1" order detection with floating counters, and a pinned product card with a stock countdown; drop in children to use a real video.