Blog · · 6 min
Bundle quantity selector component for React
No general-purpose library ships this one. It is a radio group of cards, never a quantity stepper, and getting that wrong is what makes a DTC page look fake. Full spec below — three or four tiers, non-linear gaps, unit price under the total, saving shown in currency, one tier preselected — plus the accessibility markup and the mobile rules.
A bundle quantity selector is a radio group of cards, not a quantity stepper. Three or four tiers, one of them preselected, each card showing the total, the price per unit underneath, and the saving expressed in money rather than only a percentage. The most common implementation mistake is building it as a + / − stepper, and it is the mistake that makes a store look improvised: a stepper asks the customer to do arithmetic, a tier card does the arithmetic for them and makes the middle option obviously correct.
No general-purpose React library ships it. We read the category indexes on 24 August 2026: Tailwind Plus lists 14 ecommerce categories and 84 components, none of them a quantity tier selector; CommerCN lists eight categories — checkouts, carts, reviews, orders, categories, product details, products, promo banners — and none is this; shadcnblocks has ecommerce categories for product list, product detail, product card, shopping cart and checkout, and its pricing blocks are SaaS plan tables. Every library stops at the SaaS pricing table, which looks superficially similar and behaves completely differently.
Why a radio group and not a stepper
A stepper models how many units do you want. A tier selector models which offer are you taking. Those are different questions and they produce different revenue.
With a stepper, the customer starts at 1 and every increment is their own decision, unassisted. With tiers, the middle option is presented as a resolved choice — someone already worked out that three is the sensible number — and the customer's job shrinks to accepting or declining it. That is also why the tiers must not be a dropdown: a dropdown hides the comparison, and the comparison is the entire mechanism.
Semantically it is a radio group, so build it as one. A fieldset with a legend, then one label per tier, each wrapping a visually hidden input type=radio sharing a name. The card is styled from the input's :checked state. Do not reach for div plus onClick plus role=radiogroup — you will reimplement arrow-key navigation badly, and the native version gives you keyboard support, form submission and screen reader announcement for free.
The spec
Three or four tiers. Never two, rarely five. Two tiers is a yes-or-no question and loses the anchoring effect entirely. Five is a decision the customer postpones. Three is the working default; four is defensible when the product is genuinely consumed in a cycle, such as a monthly supplement.
Non-linear gaps between tiers. If tier 1 is 0% off, tier 2 is 10% and tier 3 is 20%, the customer reads a straight line and infers that a bigger tier would be better still. Uneven steps — 0%, 15%, 22% — make the middle tier read as the deliberate one. The related pricing rule is that the top tier should improve on the middle tier by clearly less than the middle improved on the bottom: the ladder should visibly flatten, so the middle is where the value peaks.
Total price large, unit price small and directly underneath. The unit price is what makes the discount legible without mental arithmetic. Format it as price per unit of the thing the customer thinks in — per bottle, per vial, per month — not per gram.
The saving in currency, then the percentage. "Save 24 euros" is a number a person can spend. "Save 15%" is a ratio they have to convert. Show both, money first.
One tier preselected, and it is the middle one. An unselected group is an unanswered question and it suppresses the add-to-cart. Preselect the tier you actually want to sell and label it — "most popular", "best value" — with a badge that sits on the card border, not floating above the layout.
Prices come from the server. Compute the tier totals where the cart computes them. A selector that multiplies client side will eventually disagree with the checkout by a cent, and a price that changes between the product page and the checkout is the fastest way to lose a completed order.
Mobile, which is where this actually lives
At 390 pixels the three cards must stack vertically, not shrink into a row. A row of three cards at that width leaves each one about 118 pixels wide, and the unit price becomes unreadable — at which point the whole mechanism is decorative.
Each card needs a tap target of at least 44 by 44 points. The selected state must be carried by more than colour: a border weight change, a check mark, or a filled radio dot, so it survives both a colourblind reader and a phone in sunlight.
And do not animate the price when the tier changes. A number that counts up is the most common tell of a generated storefront, it delays the information by several hundred milliseconds, and it makes the price feel negotiable.
What breaks it
- Percentages only, no money. Forces arithmetic at the exact moment you want the decision to be effortless.
- A discount that also appears as a strikethrough on the base price. Two discount mechanics in the same block read as a trick.
- A countdown attached to the tier. Unless the deadline is real and enforced, this is a fabricated urgency claim and in the EU it is a listed unfair commercial practice, not a growth tactic.
- Tiers that change the shipping promise. If tier 3 unlocks free shipping, that belongs in a separate progress element, not buried in a card.
- The selector below the fold on mobile. If the customer has to scroll past the add-to-cart to find the offer, the offer does not exist.
The order of the block
Top to bottom, on the product page: gallery, name, price, tier selector, add to cart, then the reassurance line (shipping, returns). The selector sits above the button because the button's label should be able to reflect the choice — "Add 3 bottles, 78 euros" is a button that confirms a decision rather than starting one.
If you are adding a complement after the add-to-cart, that is a different component with different rules, and the placement matters more than the offer.
Disclosure — we are building uxgen, an MCP that hands an agent components of this kind; our version of this one is shown playing in the component library. It is not installable yet, and the spec above is free to copy either way.