Blog · · 12 min
A bundle builder component (build your own box)
A bundle builder is a box with a fixed capacity, not an open cart. The count is on screen at all times, the discount is stated as a rule before it applies and only applied once the box is full, and the price per unit is visible throughout. The add button stays visible and disabled with the reason attached, because a hidden button reads as a broken page.
By uxgen
A build-your-own box is not a cart with a discount on it. It is a container with a fixed capacity, and every piece of the interface exists to answer one question the buyer keeps asking themselves: how much of it is left. 4 of 6 chosen in a fixed position, a price per unit that is true right now, a discount rule stated before it applies, and a button that stays where it is and explains why it is not ready.
The mistake that kills it is announcing the discount while the box is half empty.
Why is a fixed capacity better than an open cart?
Because a capacity converts an open question into a countable one.
An open cart asks how much do you want to spend, and the honest answer is always less. A box of six asks which six, and the buyer starts filling. The difference is not persuasion, it is the shape of the task: choosing among products is enjoyable, deciding a budget is not.
The capacity also gives you the two things a discount needs to be legible. A denominator, so 4 of 6 means something without a bar chart. And a completion event, so there is a precise moment when the price drops and the buyer can see it happen.
If you sell three SKUs, the box is how you sell more than one of them without a cross-sell that reads as a pitch. That is a different mechanic from frequently bought together, which recommends. A box does not recommend, it constrains.
Where does the discount belong in time?
Stated at the top, applied at the bottom, and never both.
The failure looks like this: the box is empty, the header reads Save 15% on any box of 6, and the total under it reads $0.00. The buyer adds two items and the total reads $18.00. They have now seen a 15% claim and a price that does not contain it, and they cannot tell whether the discount is already in the number or not. Ambiguity about whether a discount has applied is worse than no discount, because the buyer resolves it by assuming they are being handled.
The sequence that works has three states and one sentence each.
| Box state | The rule line says | The total line says |
|---|---|---|
| Empty | Fill a box of 6, keep 15% | Nothing. No total on an empty box |
| Partly filled | 2 more for 15% off | The real running total, undiscounted, and the real per-unit |
| Full | 15% applied | The discounted total, the saving in currency, the per-unit |
The second row is where the mechanic lives, and it is the same sentence shape as a free shipping remainder: the gap, in the unit the buyer can act on. 2 more for 15% off is an instruction. You are 67% of the way to your discount is a puzzle.
Show the running total undiscounted while the box is incomplete. It is the truth, and it makes the drop at completion visible. A total that already includes a discount the buyer has not earned is a number you will have to take away from them.

What does the state look like?
A reducer, because the interesting behaviour is the rejections rather than the additions.
// builder.ts
export type State = {
capacity: number
picks: Record<string, number> // sku -> quantity
notice: string | null
}
export type Action =
| { type: 'add'; sku: string; label: string }
| { type: 'remove'; sku: string }
| { type: 'clear' }
| { type: 'dismissNotice' }
export const count = (picks: State['picks']): number =>
Object.values(picks).reduce((total, qty) => total + qty, 0)
export function reducer(state: State, action: Action): State {
switch (action.type) {
case 'add': {
if (count(state.picks) >= state.capacity) {
return {
...state,
notice: `The box holds ${state.capacity}. Remove one to swap it for ${action.label}.`,
}
}
return {
...state,
notice: null,
picks: {
...state.picks,
[action.sku]: (state.picks[action.sku] ?? 0) + 1,
},
}
}
case 'remove': {
const current = state.picks[action.sku] ?? 0
if (current <= 1) {
const { [action.sku]: _removed, ...rest } = state.picks
return { ...state, notice: null, picks: rest }
}
return {
...state,
notice: null,
picks: { ...state.picks, [action.sku]: current - 1 },
}
}
case 'clear':
return { ...state, picks: {}, notice: null }
case 'dismissNotice':
return { ...state, notice: null }
}
}
The notice field is the whole point of using a reducer here. A full box that silently ignores a tap is the second worst outcome in the component, behind only a full box that accepts a seventh item. The buyer taps, nothing moves, and they tap harder. The notice is rendered into a role="status" region so it is both seen and announced, and it says what to do rather than what went wrong.
Delete a SKU from the map when its quantity reaches zero rather than storing 0. A map with zero-valued keys makes every later Object.entries render an invisible row, and that bug shows up as a phantom line in the summary two features later.
How is the discount calculated?
In basis points and integer cents, never in floating-point percentages.
// pricing.ts
export type Tier = { size: number; discountBps: number } // 1500 bps = 15%
export const TIERS: Tier[] = [
{ size: 3, discountBps: 800 },
{ size: 6, discountBps: 1500 },
{ size: 12, discountBps: 2000 },
]
export function tierFor(units: number): Tier | null {
let hit: Tier | null = null
for (const tier of TIERS) if (units >= tier.size) hit = tier
return hit
}
export function nextTier(units: number): Tier | null {
return TIERS.find((tier) => tier.size > units) ?? null
}
export type Totals = {
units: number
grossCents: number
discountCents: number
netCents: number
perUnitCents: number
}
export function totals(
lines: { priceCents: number; qty: number }[],
): Totals {
const units = lines.reduce((n, line) => n + line.qty, 0)
const grossCents = lines.reduce(
(n, line) => n + line.priceCents * line.qty,
0,
)
const bps = tierFor(units)?.discountBps ?? 0
const discountCents = Math.round((grossCents * bps) / 10_000)
const netCents = grossCents - discountCents
return {
units,
grossCents,
discountCents,
netCents,
perUnitCents: units === 0 ? 0 : Math.round(netCents / units),
}
}
Basis points are integers, so 15% is 1500 and there is no 0.15000000000000002 anywhere in the chain. Math.round once, on the discount, and derive the net from it. Round the net and the gross separately and they will eventually differ by a cent from the server, which is a support ticket rather than a rounding error.
nextTier is what feeds the instruction line. It returns the next threshold above the current count, which is what lets the component write 2 more for 15% off without hardcoding the ladder in the copy.
The arithmetic, worked
This is an arithmetic example on invented inputs. It is not a measurement, and nothing here says a box will sell. It is here because the ladder has to be chosen against a margin rather than copied, and the only way to see that is to run the numbers.
Say a unit sells at $9.00 and costs you $3.60, so the gross margin is 60%.
| Units | Gross | Tier | Discount | You charge | Per unit | Cost of goods | Margin left |
|---|---|---|---|---|---|---|---|
| 1 | $9.00 | none | $0.00 | $9.00 | $9.00 | $3.60 | $5.40 |
| 3 | $27.00 | 8% | $2.16 | $24.84 | $8.28 | $10.80 | $14.04 |
| 6 | $54.00 | 15% | $8.10 | $45.90 | $7.65 | $21.60 | $24.30 |
| 12 | $108.00 | 20% | $21.60 | $86.40 | $7.20 | $43.20 | $43.20 |
Two things fall out of that table that you cannot see from the percentages alone.
The margin per box keeps rising, which is the condition for the ladder to be worth offering at all. If a row shows less margin than the row above it, the tier is a gift.
The margin rate falls from 60% to 50% across the ladder, and at 20% discount on a 60% margin product you are handing over a third of the margin on every unit. Push the top tier to 30% and the rate falls to 43%. The ladder has a ceiling, and it is set by the cost of goods rather than by what looks generous.
If you also absorb shipping on the full box, subtract it from the last column before deciding the box is a good idea. A $6.00 parcel takes a quarter of the margin on the box of six.
The related rule for the ladder shape is in quantity breaks that raise average order value: the steps should flatten, not run in a straight line, so the middle box reads as the sensible one rather than as a stop on the way to the big one.
Why must the button stay visible while it is disabled?
Because a button that is not there reads as a page that is broken, and a buyer who cannot find the way forward does not go looking for it.
Render it, disable it, and attach the reason.
const { units } = totals(lines)
const remaining = capacity - units
const ready = remaining === 0
return (
<>
<button
type="button"
className="builder__submit"
aria-disabled={!ready}
aria-describedby="builder-hint"
onClick={() => {
if (!ready) return
addBundleToCart(lines)
}}
>
{ready ? `Add the box · ${money(netCents)}` : 'Add the box'}
</button>
<p id="builder-hint" className="builder__hint" role="status">
{ready
? `15% applied. You keep ${money(discountCents)}.`
: `${remaining} more ${remaining === 1 ? 'item' : 'items'} to complete the box.`}
</p>
</>
)
aria-disabled rather than disabled, again for the reason it matters in the variant selector: a truly disabled button leaves the tab order, so a keyboard user reaches the end of the form and finds nothing there. Kept operable, it is focusable, its description is announced, and the buyer learns the rule instead of guessing it. The onClick guard is what actually stops the action.
The hint line is the same node in both states, which means the live region announces the change from 2 more items to 15% applied without you writing a second announcement.
Put the money in the label only when the box is complete. A total on a disabled button is a price for something the buyer cannot buy yet. The general rule about totals in button labels is in add to cart button.
What does it look like at 390 pixels?
The summary has to be sticky and it has to be short. On a phone the product list is the scrolling part, and the box state is the fixed part: count, total, button. Three lines, at the bottom, above the safe area.
.builder__summary {
position: sticky;
bottom: 0;
padding-block-end: calc(0.75rem + env(safe-area-inset-bottom));
}
What does not survive the narrow width is a row of six preview slots. At 390 pixels, six cells minus the gutters leaves each one about 50 pixels wide, which is not enough to show a product and is barely enough for a tap target. Replace the slots with the sentence. 4 of 6 chosen is smaller, faster to read and does not lie about what fits.
Keep the per-unit price on screen in the summary rather than only in the list. It is the figure that makes the discount legible without arithmetic, and it is the one that stops a buyer opening a calculator halfway through filling the box.
We build uxgen, an MCP server that hands Claude 168 selling components and writes them into the store as HTML you own, from $19 a month. The pricing helpers above are free to lift either way, and the basket pieces are in the commerce kit under MIT.
FAQ
What is a bundle builder component?
A bundle builder, or build-your-own box, lets the buyer assemble a fixed number of items from a selection and buy them as one discounted unit. The defining property is the fixed capacity: the interface always shows how many of the slots are filled, which converts an open spending decision into a countable task with a visible end.
When should the bundle discount appear?
State the rule from the start and apply the money only when the box is complete. A discounted total shown on a half-filled box makes it impossible for the buyer to tell whether the price on screen already contains the discount, and that ambiguity is worse than no offer at all. While the box is incomplete, show the true undiscounted running total and a line saying how many items remain.
Should the add button be hidden until the box is full?
No. Hide it and the buyer reads the page as broken and stops looking for the way forward. Render it in place, mark it with aria-disabled rather than the disabled attribute so it stays in the tab order and its explanation is announced, and attach a short line saying exactly how many items are still needed.
How do I calculate bundle tier discounts without rounding errors?
Store the discount as basis points, which are integers, and every price in integer cents. Compute the discount once with Math.round(gross * bps / 10000) and derive the net by subtracting it, rather than rounding the gross and the net independently. Two separate roundings will eventually disagree with the server by a cent, which surfaces at checkout as a price that changed.