Group Buy Progress (React)
A head-count group-buy card for React: stacked participant avatars, tiered group prices, a deadline countdown, a CTA that flips to "group formed" with a card pulse and confetti the moment the threshold is hit, and an automatic refund note if the deadline passes short of the minimum.
The classic "let's get enough people" interface of Taiwanese community and office group buys: the product row shows the current group price — every time the head count crosses a tier threshold the old price gets struck through and the new one rolls in from below; the progress bar is scaled in people, with a node and price tag at every tier; participants appear as a stack of initial-letter avatars with deterministic hues (up to 6 plus "+N", new members slide in from the right); the deadline countdown ticks every second. Pressing "+1 跟團" (join) calls onJoin; the instant the minimum is reached the CTA becomes "已成團 🎉" (group formed), the whole card pulses slightly and confetti bursts out. If the deadline passes without reaching the minimum the card switches to failed and shows "未成團將全額退款" (full refund if the group does not form). The status supports both controlled and uncontrolled modes.
npx shadcn@latest add https://webberui.com/r/group-buy-progress.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<GroupBuyProgress />
Installation
npx shadcn@latest add https://webberui.com/r/group-buy-progress.jsonOr, once registries are configured in components.json, install it as @webberui/group-buy-progress.
Usage
import * as React from "react";
import {
GroupBuyProgress,
type GroupBuyParticipant,
} from "@/components/ui/group-buy-progress";
function OfficeCoffeeGroupBuy() {
// Your data layer owns the roster; the component only reads it and asks you
// to append a member through onJoin when "+1 跟團" is pressed
const [participants, setParticipants] = React.useState<GroupBuyParticipant[]>([
{ id: "u1", name: "林曉彤" },
{ id: "u2", name: "陳建宏" },
{ id: "u3", name: "黃雅琪" },
]);
return (
<GroupBuyProgress
product={{ name: "手沖濾掛咖啡 12 入組", subtitle: "深焙", price: 520 }}
tiers={[
{ count: 5, price: 450 },
{ count: 10, price: 399 },
]}
participants={participants}
deadline="2026-09-30T23:59:59+08:00"
maxSpots={20}
onJoin={() =>
setParticipants((list) => [...list, { id: "me", name: "我" }])
}
onFormed={() => console.log("Group formed — tell the organizer to close the order")}
onChange={(status) => console.log("status:", status)}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
product | GroupBuyProduct | — | Product name, list price, and subtitle |
tiers | GroupBuyTier[] | — | Tiered group prices (ascending by count; the component re-sorts and drops non-positive entries) |
participants | GroupBuyParticipant[] | — | The current roster; head count, progress, price, and avatars are all derived from it |
deadline | string | number | Date | — | Deadline to form the group (ISO string, millisecond timestamp, or Date); if it passes below the minimum the card switches to failed |
maxSpots | number | — | Cap on spots; once reached the join button becomes "已額滿" (full), and it also becomes the full scale of the progress bar |
minCount | number | smallest count in tiers | Minimum head count to form the group; specify it separately when the forming threshold differs from the lowest price tier |
value | GroupBuyStatus | — | Controlled status; when provided, the outside decides whether the group is open, formed, or failed |
defaultValue | GroupBuyStatus | derived from head count | Initial status in uncontrolled mode; when omitted it is "formed" if the minimum is already met, otherwise "open" |
onChange | (status: GroupBuyStatus) => void | — | Called when the status should change (minimum reached → formed, deadline passed short of it → failed); update value here in controlled mode |
onJoin | () => void | — | Called when the join button is pressed; the component never mutates the roster itself, so append the new member to participants here |
onFormed | () => void | — | Called once when the status actually becomes formed |
currency | string | "NT$" | Currency prefix shown before every amount |
maxAvatars | number | 6 | How many avatars to show in the stack (the most recent joiners); the rest collapse into "+N" |
showCountdown | boolean | true | Whether to show the deadline countdown |
compact | boolean | false | Compact mode: tighter spacing and smaller avatars, hides the subtitle, tier price tags, and hint line — for embedding in product lists |
joinLabel | string | — | Join button text; a shortcut for labels.join that wins when both are provided |
labels | Partial<GroupBuyProgressLabels> | built-in Traditional Chinese | Override interface copy (partial overrides are fine; the rest falls back to the defaults) |
className | string | — | Forwarded to the outermost container |
GroupBuyStatus
"open" | "formed" | "failed" — recruiting, group formed, group failed.
GroupBuyProduct
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Product name |
price | number | — | List price: the unit price shown before any tier is reached |
subtitle | string | — | Subtitle / spec line (hidden in compact mode) |
GroupBuyTier
| Field | Type | Default | Description |
|---|---|---|---|
count | number | — | Threshold head count (positive integer) |
price | number | — | Unit price once the threshold is reached |
GroupBuyParticipant
| Field | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique id, also the seed for the avatar hue (the same id always gets the same color) |
name | string | — | Display name; the avatar shows its first character |
color | string | — | Custom avatar background (any CSS color); when omitted a deterministic hue is derived from id |
GroupBuyProgressLabels
Strings containing {count} / {price} have the actual values substituted at render time.
| Field | Type | Default | Description |
|---|---|---|---|
join | string | "+1 跟團" (join) | Join button text |
formed | string | "已成團 🎉" (group formed) | Button text after the group forms |
failed | string | "未成團" (not formed) | Button text when the group fails; also shown on the right of the progress row |
full | string | "已額滿" (full) | Button text when all spots are taken |
refundNote | string | "未成團將全額退款" (full refund if the group does not form) | Refund note shown under the button when the group fails |
formedNote | string | "已成團,等待主購結單" (formed, waiting for the organizer to close the order) | Hint shown on the right of the progress row after forming |
joined | string | "{count} 人已跟團" (N people joined) | Head count text |
untilFormed | string | "再 {count} 人成團" (N more to form) | How many more are needed to form the group |
nextTier | string | "再 {count} 人降至 {price}" (N more drops the price to X) | How many more until the next tier (shown under the price) |
bestPrice | string | "已達最低團購價" (lowest group price reached) | Hint once the last tier is reached |
original | string | "原價" (list price) | Prefix when the struck-through price is the list price |
countdown | string | "成團倒數" (countdown to close) | Countdown section title |
ended | string | "已截止" (closed) | Countdown text after the deadline |
tier | string | "{count} 人" (N people) | Head-count tag on each tier node of the progress bar |
threshold | string | "成團" (forms) | Tag on the minimum-count node (only shown when the minimum differs from every tier count) |
more | string | "還有 {count} 人" (N more people) | Accessible text for the "+N" avatar bubble |
day / hour / minute / second | string | "天" / "時" / "分" / "秒" (d / h / m / s) | Countdown units |
resolveGroupBuyTier(tiers, count) (returns the tier currently reached and the next one) and formatGroupBuyPrice(amount, currency) (thousands-grouped amount string) are also named exports, so the cart or checkout page can reuse the exact same pricing rule.
How it works
- Tier pricing: the current unit price is the price of the highest tier reached, or
product.pricebefore any tier. Whenever the price changes, the previous price becomes the struck-through one (the list price when a tier is already reached at mount) and the new price rolls in from below - Progress scale: the full scale is
maxSpots; when omitted it is the larger of the highest tier count and the minimum. Every tier gets a node on the track plus a "count / price" tag; tags at the two ends automatically align to the edge so they never spill past the track - Formed and failed: in uncontrolled mode the card switches to
formedas soon as the head count reachesminCountwhile open, and tofailedifdeadlinepasses short of it. In controlled mode the component only suggests the next status throughonChangeand you decide whether to updatevalue. Formed and failed are terminal — the card never changes automatically after that - Countdown:
Date.now()is only read inside an effect; SSR and the first render show--placeholders and the numbers start ticking after mount, so hydration stays consistent. An invaliddeadlinehides the countdown and never expires - Avatars: no images; the background hue is hashed from
id(stable per person), or set explicitly withcolor. The stack shows the most recentmaxAvatarsjoiners and folds earlier ones into "+N"
Accessibility
- The progress bar is
role="progressbar"witharia-valuenow/aria-valuemaxin people andaria-valuetextreading "N 人已跟團" (N people joined); the head-count text lives in anaria-live="polite"region, so screen readers announce it after each join - The countdown is
role="timer"with a full "days / hours / minutes / seconds"aria-label; the refund note when the group fails isrole="status" - The avatar stack is a semantic
list; each member'slicarries the name and "+N" carries "N more people"; the visual initial is hidden from assistive technology - The join button is a native
button(type="button"); when formed, failed, or full it isdisabledwitharia-disabledkept in sync, and keyboard behavior plus thefocus-visibleoutline follow the browser defaults - When the user has "reduce motion" enabled at the system level, the price roll, avatar slide-in, card pulse, and confetti are disabled and only the instant text and color changes remain
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.
Morphing Dialog
Click a card and it scales and morphs seamlessly into a centered dialog with a shared-element animation, then shrinks back to the original card position on close.