Commerce & Checkout

Group Buy Progress

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 Group" (join) calls onJoin; the instant the minimum is reached the CTA becomes "Group Completed 🎉" (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 for Uncompleted Group" (full refund if the group does not form). The status supports both controlled and uncontrolled modes.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add group-buy-progress.en

New project or installation trouble? Start here →

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
Active edited usageDefault props
<GroupBuyProgress/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/group-buy-progress.en.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 Join Group" is pressed
  const [participants, setParticipants] = React.useState<GroupBuyParticipant[]>([
    { id: "u1", name: "Lin Xiaotong" },
    { id: "u2", name: "Chen Jianhong" },
    { id: "u3", name: "Huang Yaqi" },
  ]);

  return (
    <GroupBuyProgress
      product={{ name: "Hand-drip drip coffee, 12-pack", subtitle: "Dark roast", 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: "Me" }])
      }
      onFormed={() => console.log("Group formed — tell the organizer to close the order")}
      onChange={(status) => console.log("status:", status)}
    />
  );
}

Props

PropTypeDefaultDescription
productGroupBuyProduct—Product 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 | Date—Deadline to form the group (ISO string, millisecond timestamp, or Date); if it passes below the minimum the card switches to failed
maxSpotsnumber—Cap on spots; once reached the join button becomes "Full Quota" (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
valueGroupBuyStatus—Controlled 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) => 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
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
joinLabelstring—Join 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)
classNamestring—Forwarded to the outermost container

GroupBuyStatus

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

GroupBuyProduct

FieldTypeDefaultDescription
namestring—Product name
pricenumber—List price: the unit price shown before any tier is reached
subtitlestring—Subtitle / spec line (hidden in compact mode)

GroupBuyTier

FieldTypeDefaultDescription
countnumber—Threshold head count (positive integer)
pricenumber—Unit price once the threshold is reached

GroupBuyParticipant

FieldTypeDefaultDescription
idstring—Unique id, also the seed for the avatar hue (the same id always gets the same color)
namestring—Display name; the avatar shows its first character
colorstring—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.

FieldTypeDefaultDescription
joinstring"+1 Join Group" (join)Join button text
formedstring"Group Completed 🎉" (group formed)Button text after the group forms
failedstring"Group Not Completed" (not formed)Button text when the group fails; also shown on the right of the progress row
fullstring"Full Quota" (full)Button text when all spots are taken
refundNotestring"Full Refund for Uncompleted Group" (full refund if the group does not form)Refund note shown under the button when the group fails
formedNotestring"Group Completed, Awaiting Main Purchase Order" (formed, waiting for the organizer to close the order)Hint shown on the right of the progress row after forming
joinedstring"{count} People Joined the Group" (N people joined)Head count text
untilFormedstring"Another {count} People Joined the Group" (N more to form)How many more are needed to form the group
nextTierstring"[Count] More people reduced to [price]" (N more drops the price to X)How many more until the next tier (shown under the price)
bestPricestring"Lowest group-buying price reached" (lowest group price reached)Hint once the last tier is reached
originalstring"Original price" (list price)Prefix when the struck-through price is the list price
countdownstring"Countdown to group purchase" (countdown to close)Countdown section title
endedstring"Closed" (closed)Countdown text after the deadline
tierstring"[Count] people" (N people)Head-count tag on each tier node of the progress bar
thresholdstring"Group purchase complete" (forms)Tag on the minimum-count node (only shown when the minimum differs from every tier count)
morestring"[Count] people remaining" (N more people)Accessible text for the "+N" avatar bubble
day / hour / minute / secondstring"days" / "Time" / "Points" / "s" (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 people joined" (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