WebberUI

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.

Loading preview…
npx shadcn@latest add https://webberui.com/r/group-buy-progress.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.

5
<GroupBuyProgress />

Installation

npx shadcn@latest add https://webberui.com/r/group-buy-progress.json

Or, 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

PropTypeDefaultDescription
productGroupBuyProductProduct name, list price, and subtitle
tiersGroupBuyTier[]Tiered group prices (ascending by count; the component re-sorts and drops non-positive entries)
participantsGroupBuyParticipant[]The current roster; head count, progress, price, and avatars are all derived from it
deadlinestring | number | DateDeadline to form the group (ISO string, millisecond timestamp, or Date); if it passes below the minimum the card switches to failed
maxSpotsnumberCap on spots; once reached the join button becomes "已額滿" (full), and it also becomes the full scale of the progress bar
minCountnumbersmallest count in tiersMinimum head count to form the group; specify it separately when the forming threshold differs from the lowest price tier
valueGroupBuyStatusControlled status; when provided, the outside decides whether the group is open, formed, or failed
defaultValueGroupBuyStatusderived from head countInitial status in uncontrolled mode; when omitted it is "formed" if the minimum is already met, otherwise "open"
onChange(status: GroupBuyStatus) => voidCalled when the status should change (minimum reached → formed, deadline passed short of it → failed); update value here in controlled mode
onJoin() => voidCalled when the join button is pressed; the component never mutates the roster itself, so append the new member to participants here
onFormed() => voidCalled once when the status actually becomes formed
currencystring"NT$"Currency prefix shown before every amount
maxAvatarsnumber6How many avatars to show in the stack (the most recent joiners); the rest collapse into "+N"
showCountdownbooleantrueWhether to show the deadline countdown
compactbooleanfalseCompact mode: tighter spacing and smaller avatars, hides the subtitle, tier price tags, and hint line — for embedding in product lists
joinLabelstringJoin button text; a shortcut for labels.join that wins when both are provided
labelsPartial<GroupBuyProgressLabels>built-in Traditional ChineseOverride interface copy (partial overrides are fine; the rest falls back to the defaults)
classNamestringForwarded to the outermost container

GroupBuyStatus

"open" | "formed" | "failed" — recruiting, group formed, group failed.

GroupBuyProduct

FieldTypeDefaultDescription
namestringProduct name
pricenumberList price: the unit price shown before any tier is reached
subtitlestringSubtitle / spec line (hidden in compact mode)

GroupBuyTier

FieldTypeDefaultDescription
countnumberThreshold head count (positive integer)
pricenumberUnit price once the threshold is reached

GroupBuyParticipant

FieldTypeDefaultDescription
idstringUnique id, also the seed for the avatar hue (the same id always gets the same color)
namestringDisplay name; the avatar shows its first character
colorstringCustom 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.

FieldTypeDefaultDescription
joinstring"+1 跟團" (join)Join button text
formedstring"已成團 🎉" (group formed)Button text after the group forms
failedstring"未成團" (not formed)Button text when the group fails; also shown on the right of the progress row
fullstring"已額滿" (full)Button text when all spots are taken
refundNotestring"未成團將全額退款" (full refund if the group does not form)Refund note shown under the button when the group fails
formedNotestring"已成團,等待主購結單" (formed, waiting for the organizer to close the order)Hint shown on the right of the progress row after forming
joinedstring"{count} 人已跟團" (N people joined)Head count text
untilFormedstring"再 {count} 人成團" (N more to form)How many more are needed to form the group
nextTierstring"再 {count} 人降至 {price}" (N more drops the price to X)How many more until the next tier (shown under the price)
bestPricestring"已達最低團購價" (lowest group price reached)Hint once the last tier is reached
originalstring"原價" (list price)Prefix when the struck-through price is the list price
countdownstring"成團倒數" (countdown to close)Countdown section title
endedstring"已截止" (closed)Countdown text after the deadline
tierstring"{count} 人" (N people)Head-count tag on each tier node of the progress bar
thresholdstring"成團" (forms)Tag on the minimum-count node (only shown when the minimum differs from every tier count)
morestring"還有 {count} 人" (N more people)Accessible text for the "+N" avatar bubble
day / hour / minute / secondstring"天" / "時" / "分" / "秒" (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.price before 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 formed as soon as the head count reaches minCount while open, and to failed if deadline passes short of it. In controlled mode the component only suggests the next status through onChange and you decide whether to update value. 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 invalid deadline hides the countdown and never expires
  • Avatars: no images; the background hue is hashed from id (stable per person), or set explicitly with color. The stack shows the most recent maxAvatars joiners and folds earlier ones into "+N"

Accessibility

  • The progress bar is role="progressbar" with aria-valuenow / aria-valuemax in people and aria-valuetext reading "N 人已跟團" (N people joined); the head-count text lives in an aria-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 is role="status"
  • The avatar stack is a semantic list; each member's li carries 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 is disabled with aria-disabled kept in sync, and keyboard behavior plus the focus-visible outline 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

On this page