Bundle Builder
A bundle-building machine — click a product thumbnail and it FLIPs into a slot, the total rolls to its new value, and filling the required number of slots completes the discount ring and unlocks the discount.
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
npx shadcn@latest add "https://webberui.com/r/bundle-builder.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<BundleBuilder />
Installation
npx shadcn@latest add "https://webberui.com/r/bundle-builder.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/bundle-builder.
Usage
import {
BundleBuilder,
type BundleProduct,
} from "@/components/ui/bundle-builder";
const products: BundleProduct[] = [
{ id: "a", name: "Croissant", price: 65, thumbnail: "🥐" },
{ id: "b", name: "Latte", price: 120, thumbnail: "☕" },
{ id: "c", name: "Salad", price: 150, thumbnail: "🥗" },
{ id: "d", name: "Cake", price: 95, thumbnail: "🍰" },
];
<BundleBuilder
products={products}
columns={4}
slots={3}
discount={0.2}
currency="NT$"
onAddToBundle={(items, total) => console.log(items, total)}
/>;Click a product thumbnail at the top and it flies into a slot below with a FLIP animation. Once the slots reach the configured count, the discount ring fills, the discount label pops out, and the total rolls to the discounted price. Click the product or the slot again to remove it.
Controlled mode
Passing selectedIds puts it in controlled mode, with outside state driving the selection:
const [ids, setIds] = React.useState<string[]>([]);
<BundleBuilder
products={products}
selectedIds={ids}
onSelectionChange={setIds}
onUnlock={() => console.log("discount unlocked")}
/>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
products | BundleProduct[] | — | List of products to show in the product grid |
slots | number | 3 | Number of slots that must be filled to unlock the discount |
columns | number | 3 | Number of columns in the product grid |
discount | number | 0.2 | Discount rate applied once the slots are filled (0–1) |
currency | string | "$" | Currency symbol |
locale | string | "en-US" | Locale used for the number's thousands separators |
decimals | number | 0 | Number of decimal places shown in prices |
selectedIds | string[] | — | Controlled: ids of the currently selected products (in the order added) |
defaultSelectedIds | string[] | — | Initial selection in uncontrolled mode |
onSelectionChange | (ids: string[]) => void | — | Fires when the selection changes |
onUnlock | () => void | — | Fires once when the bundle goes from not full to full and the discount unlocks |
onAddToBundle | (products, total) => void | — | Fires when the primary button is clicked, returning the selected products and the discounted total |
actionLabel | React.ReactNode | "Add bundle" | Text on the primary button |
className | string | — | Styles for the outer container |
BundleProduct
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier, used for controlled mode and FLIP matching |
name | string | Product name |
price | number | Unit price (before discount) |
thumbnail | React.ReactNode | Thumbnail content — an emoji, an icon, or any node |
thumbnailClassName | string | Overrides the thumbnail background (Tailwind class) |
How it works
- FLIP fly-in: implemented with Motion's
layoutIdshared layout animation. Once selected, the same thumbnail moves from the product grid into a slot and Motion measures the before and after positions automatically to perform the FLIP transition; a check mark placeholder is left in the grid cell to keep the layout stable. - Rolling total:
useMotionValue+animateroll smoothly to the new price, updating frame by frame without triggering a React re-render; once the bundle is full, the discount is applied and the struck-through original price and the amount saved are shown. - Discount ring: an SVG ring progress fills as slots are filled; on reaching the goal the ring turns the accent color and pops out a check mark and the discount percentage.
- When the slots are full, unselected products are disabled; click a slot or an already-selected product to remove it.
Accessibility
- Products are native
buttons carryingaria-pressedto mark their selected state, with anaria-labelcontaining the name and price. - The remove key on a slot carries an
aria-labelof "Remove … from the bundle"; the discount ring is decorative and markedaria-hidden. - Selection progress and unlock state are announced immediately through a
role="status"aria-liveregion. - When the user has "reduce motion" enabled at the system level, the FLIP, the rolling numbers, and the discount ring all land instantly with no transition animation.
Chromatic Variant Stage
A product display stage where picking a swatch performs three transitions at once — the background bleeds outward radially, the product image cross-dissolves, and the price and model number flip to their new values.
Anchored Buy Rail
An anchored buy rail — a long-scrolling left column beside a sticky buy panel that FLIPs down into a mini checkout capsule as you scroll, then expands again as the reviews section comes near.