KQ-1336 · BUNDLE/BUILDER
Bundle Builder
A bundle builder where picking a product sends its bag flying into the next empty compartment — a thrown arc, a straight glide or a lift and a drop — and the landing is the event: the bag squashes and recovers on recoil, the box dips and the lid hops, and only then do the lid's savings and the totals roll on snap. Bags can also be dragged 1:1 and thrown at the box, gliding in with the release velocity; taking one out flies it home to its shelf while the bags beside it slide over on glide, and the lid, hinged in perspective, closes in on snap as the box fills. Every product has a real toggle and every filled compartment a real remove button, so the keyboard reaches each end state, and landings are announced with the new total.
box empty · pick 3
Install
One command — the source lands in your repo. Or copy it from the Code tab.
$ pnpm dlx shadcn@latest add @kinetiq/bundle-builder
Props
- slots
- number
- default 3
- Compartments in the box, 2 to 5. A box that shrinks lets go of the bags that no longer fit.
- fly
- "arc" | "direct" | "drop"
- default "arc"
- How a picked bag travels: a thrown arc that turns with its direction, a straight glide, or a lift over the compartment and a fall into it with a bigger squash.
- savings
- "flat" | "tiered" | "free"
- default "flat"
- The deal the lid shows: percent off a full box, a discount that climbs with every bag to percent, or the cheapest bag free once the box is full.
- products
- BundleProduct[]
- default defaultBundleProducts
- The shelf: id, name, tasting notes, price, a label tint, roast 1 to 5, and stock (0 is sold out).
- value / defaultValue
- string[]
- default []
- The picked ids in compartment order. Controlled or uncontrolled.
- onValueChange
- (ids: string[]) => void
- default —
- Fires from the press, tap, drop or remove that changed the box. A controlled host that refuses a pick sees the bag fly home instead of landing.
- percent
- number
- default 15
- The full box's discount for flat, and the top of the ladder for tiered, in percent.
- onSubmit
- (ids: string[]) => void | Promise<unknown>
- default —
- The full box was added to the cart. A returned promise shows a pending ring, then Added with a check on flick, or Try again if it rejects.
- submitLabel
- string
- default "Add box to cart"
- The submit button's words; the total follows them.
- format
- (amount: number) => string
- default —
- Money. Defaults to Intl currency formatting in locale and currency.
- locale
- string
- default "en-US"
- Locale for the default money format.
- currency
- string
- default "USD"
- Currency for the default money format.
- status
- "ready" | "loading" | "error"
- default "ready"
- Whether the shelf has arrived: a skeleton while loading, a message and Try again on error.
- onRetry
- () => void
- default —
- Try again was pressed after the shelf failed to load.
- title
- string
- default "Tasting box"
- The builder's heading.
- subtitle
- string
- default —
- The line under the heading. Defaults to the offer in words, from slots, savings and percent.
- label
- string
- default —
- The region's accessible name. Defaults to the title.
- sound
- boolean
- default false
- A pop as a bag leaves the shelf or the box, a thock timed to its landing (higher as the box fills), softer and lower when it lands home or a full box refuses it.
- disabled
- boolean
- default false
- Look only: nothing can be picked, dragged, removed or added to the cart.
- className
- string
- default —
- Classes for the root; its scroller is at most 560px tall and flights are clipped to the root.
| Prop | Type | Default | Description |
|---|---|---|---|
| slots | number | 3 | Compartments in the box, 2 to 5. A box that shrinks lets go of the bags that no longer fit. |
| fly | "arc" | "direct" | "drop" | "arc" | How a picked bag travels: a thrown arc that turns with its direction, a straight glide, or a lift over the compartment and a fall into it with a bigger squash. |
| savings | "flat" | "tiered" | "free" | "flat" | The deal the lid shows: percent off a full box, a discount that climbs with every bag to percent, or the cheapest bag free once the box is full. |
| products | BundleProduct[] | defaultBundleProducts | The shelf: id, name, tasting notes, price, a label tint, roast 1 to 5, and stock (0 is sold out). |
| value / defaultValue | string[] | [] | The picked ids in compartment order. Controlled or uncontrolled. |
| onValueChange | (ids: string[]) => void | — | Fires from the press, tap, drop or remove that changed the box. A controlled host that refuses a pick sees the bag fly home instead of landing. |
| percent | number | 15 | The full box's discount for flat, and the top of the ladder for tiered, in percent. |
| onSubmit | (ids: string[]) => void | Promise<unknown> | — | The full box was added to the cart. A returned promise shows a pending ring, then Added with a check on flick, or Try again if it rejects. |
| submitLabel | string | "Add box to cart" | The submit button's words; the total follows them. |
| format | (amount: number) => string | — | Money. Defaults to Intl currency formatting in locale and currency. |
| locale | string | "en-US" | Locale for the default money format. |
| currency | string | "USD" | Currency for the default money format. |
| status | "ready" | "loading" | "error" | "ready" | Whether the shelf has arrived: a skeleton while loading, a message and Try again on error. |
| onRetry | () => void | — | Try again was pressed after the shelf failed to load. |
| title | string | "Tasting box" | The builder's heading. |
| subtitle | string | — | The line under the heading. Defaults to the offer in words, from slots, savings and percent. |
| label | string | — | The region's accessible name. Defaults to the title. |
| sound | boolean | false | A pop as a bag leaves the shelf or the box, a thock timed to its landing (higher as the box fills), softer and lower when it lands home or a full box refuses it. |
| disabled | boolean | false | Look only: nothing can be picked, dragged, removed or added to the cart. |
| className | string | — | Classes for the root; its scroller is at most 560px tall and flights are clipped to the root. |