Blog · · 12 min
The variant selector that does not lose the sale
A dropdown hides the options, and with them the price that changes and the size that is gone. Render every value as a visible radio below roughly seven per axis, group above that. An impossible combination is resolved by moving the other axis, never by accepting the click and then showing an error. The price updates in a reserved line with tabular figures so nothing moves.
By uxgen
A variant selector has one job that is not choosing: it has to show what choosing costs. Which values exist, which are gone, and what happens to the price. A <select> does none of that until it is opened, which means the buyer decides whether to open it before they know there is anything behind it.
So the default is a visible group. Below about seven values on an axis, render them all as radios. Above that, group them or search them. And never let the buyer click a combination that does not exist and then tell them off for it.
Why does a dropdown cost you the sale?
Three things are hidden inside a collapsed <select>, and each of them is a reason to buy.
The price that moves. If 250 g is €18 and 1 kg is €52, the range is an argument. Collapsed, there is one price on the page and the buyer either accepts it or leaves.
The stock. A size that is out is information the buyer needs before they invest attention in the product. Behind a chevron it arrives after they have chosen, which is the point at which the absence reads as a failure of the shop.
The size of the offer. Four colours displayed is a range. Four colours in a dropdown is a form field. On a native mobile browser it is worse than a form field, because tapping it hands the screen to the operating system's wheel picker and the product photograph disappears entirely.
That last one is the whole mobile argument. The buyer is looking at a picture of a jar. They tap the size field, an opaque tray covers two thirds of the screen, they spin a wheel, and they come back to a page that may or may not have changed. Nothing about that sequence keeps the product in view while the decision is made.
How many values before you group them?
The threshold is not a law, but you need one, and it should be written down rather than decided per page.
| Values on one axis | Treatment | Why |
|---|---|---|
| 2 to 7 | All visible, radio group | Every value, its price and its availability read in one glance |
| 8 to 20 | Visible group, wrapped, with the unavailable ones still shown | Wrapping costs vertical space, which is cheaper than hiding |
| 20 to 60 | Grouped under headings, or a size chart that opens as a dialog | The list is now a reference document, not a choice |
| more than 60 | Searchable field with the first matches shown | This is a catalogue axis, not a variant axis |
Two axes with seven values each is forty-nine combinations, and the buyer never sees the grid. They see seven and seven, and the component resolves the pair. That is why the interesting code is not in the rendering.

What do you do with the combination that does not exist?
Medium exists. Black exists. Medium in black does not. This is where most selectors fail, and the failure has a signature: the buyer taps, the page accepts it, and then the add-to-cart button turns into an error message.
There are three states hiding under the word unavailable, and they deserve three different treatments.
| Case | What the buyer sees | What happens on tap |
|---|---|---|
| Exists, but not with the current other axis | Rendered normally | The other axis moves to a value that pairs with it, announced |
| Exists as a product, out of stock everywhere | Rendered, struck through, aria-disabled | Opens the back-in-stock form for that variant |
| Never manufactured | Not rendered at all | Nothing |
The first row is the one that matters. The buyer who taps M has told you the size they want. Their colour was a default they may not have chosen deliberately. So keep the size and move the colour, then say so out loud.
// resolve.ts
export type Variant = {
id: string
size: string
color: string
priceCents: number
inStock: boolean
}
export type Axis = 'size' | 'color'
export type Selection = Record<Axis, string>
export function resolve(
variants: Variant[],
current: Selection,
axis: Axis,
value: string,
): { next: Selection; moved: Axis | null; soldOut: boolean } {
const wanted: Selection = { ...current, [axis]: value }
const exact = variants.find(
(v) => v.size === wanted.size && v.color === wanted.color && v.inStock,
)
if (exact) return { next: wanted, moved: null, soldOut: false }
// The axis the buyer just touched is the one they meant. Move the other one.
const other: Axis = axis === 'size' ? 'color' : 'size'
const fallback = variants.find((v) => v[axis] === value && v.inStock)
if (!fallback) {
// This value is gone in every combination: keep it selected and let the
// component render the back-in-stock form rather than a dead button.
return { next: wanted, moved: null, soldOut: true }
}
return {
next: { size: fallback.size, color: fallback.color },
moved: fallback[other] === current[other] ? null : other,
soldOut: false,
}
}
moved is what the component announces. Not a toast, not a red border: a sentence in the same live region the cart drawer uses.
Size M selected. Colour changed to Navy, because M is not available in Black.
That sentence does three jobs at once. It confirms what the buyer did, it explains the thing that moved without them asking, and it names the constraint so they can decide whether the constraint is acceptable. An error message does one job and it is the wrong one.
The fallback search is find, which takes the first matching variant in array order. That means the order your API returns variants in is a merchandising decision. Sort the array so the first in-stock match is the one you would rather sell.
What does the accessible markup look like?
Native radios, one group per axis, the card styled from the input's checked state. Not div with onClick.
type Props = {
legend: string
name: string
values: { value: string; label: string; available: boolean; soldOut: boolean }[]
selected: string
onSelect: (value: string) => void
}
export function VariantAxis({ legend, name, values, selected, onSelect }: Props) {
return (
<fieldset className="axis">
<legend className="axis__legend">
{legend}
<span className="axis__current"> {selected}</span>
</legend>
<div className="axis__values">
{values.map((v) => (
<label
key={v.value}
className="chip"
data-sold-out={v.soldOut || undefined}
>
<input
type="radio"
name={name}
value={v.value}
checked={selected === v.value}
aria-describedby={v.soldOut ? `${name}-${v.value}-note` : undefined}
onChange={() => onSelect(v.value)}
/>
<span className="chip__label">{v.label}</span>
{v.soldOut && (
<span id={`${name}-${v.value}-note`} className="sr-only">
sold out, notify me when it returns
</span>
)}
</label>
))}
</div>
</fieldset>
)
}
The <legend> carries the axis name and the current value, so a screen reader announces Size, M, radio group rather than radio group and leaves the buyer to work out which of the two groups they are in.
Note what is absent: the disabled attribute. A disabled radio is removed from the tab order, which means a keyboard user arrows straight past it and never learns the value exists. The strike-through is visual only and communicates nothing to a screen reader, which is why the state is repeated in a visually hidden span tied to the input with aria-describedby. Keeping the input operable is also what lets the sold-out value open the back-in-stock form, which is a conversion path rather than a dead end.
The visual strike-through has a contrast trap in it. The reflex is to drop the sold-out chip to a light grey, and a light grey label on a light background usually fails the 4.5:1 contrast ratio that WCAG asks of body text. Carry the state with a diagonal rule through the label and a thinner border, and keep the text at full contrast. A sold-out size the buyer cannot read is a size they will try to select.
Why does the layout jump when the price changes?
Two causes, both fixable in CSS.
The first is the digits. Most interface fonts ship proportional figures, so a 1 is narrower than a 0, and €18.00 becoming €52.00 changes the width of the price. On a centred layout everything to the right of it slides.
.price {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum' 1; /* older engines */
}
The second is the line that appears. A saving line, a per-unit line or a stock note that renders only for some variants adds a row to the layout, and the add-to-cart button moves under the buyer's thumb between the moment they aimed and the moment they tapped. Reserve the row:
.price-block__note {
display: block;
min-height: 1.25rem; /* the note's own line height, always occupied */
}
And format with Intl, not a template string:
export function money(cents: number, currency: string, locale: string): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(cents / 100)
}
A hardcoded $ with a dot separator is wrong across most of Europe, and a price is the one string on the page a buyer reads as a number rather than as text.
Do not animate the transition. A price that counts up delays the information by several hundred milliseconds and makes the figure feel negotiable. The same argument applies to the tier cards in the bundle quantity selector, and for the same reason.
What does the image do while the axis changes?
If colour is an axis, the gallery has to follow it, and this is where a fast selector becomes a slow page. The buyer taps a colour, the component swaps the src, and the browser fetches an image it has never seen. On a phone connection the product disappears for a second.
Preload the primary image of each colour at the same time you render the chips:
{colors.map((c) => (
<link key={c.value} rel="preload" as="image" href={c.heroSrc} />
))}
And give the gallery frame a fixed aspect ratio so the swap cannot resize it:
.gallery__frame { aspect-ratio: 4 / 5; }
.gallery__frame img { width: 100%; height: 100%; object-fit: cover; }
A colour axis with no image response is the other version of hiding the options. The buyer picked Navy and the page shows them Black, so they have to trust a word instead of looking at a thing.
Where does the selector sit?
Above the button, always. The button's label reflects the choice, which means the choice has to be resolved before the button is read. Add M, Navy · €52.00 is a confirmation. Add to cart above an unresolved selector is a question the buyer has to answer twice.
Order on the product page: gallery, name, price, variant axes, quantity or tier, add to cart, reassurance line. The rules for the button itself are in add to cart button, and the case for tiers rather than a quantity stepper is in quantity breaks that raise average order value.
uxgen is an MCP server that gives Claude 168 selling components and writes them into your store as HTML you own, from $19 a month. The resolution logic above is free to copy whether or not you ever install it.
FAQ
Should a variant selector be a dropdown or buttons?
Buttons, up to roughly seven values on an axis. A collapsed dropdown hides the price differences between variants, hides which values are out of stock, and on mobile hands the screen to the operating system's wheel picker, which covers the product photograph while the buyer decides. Above seven values, group them under headings rather than collapsing them.
How should I handle an out-of-stock variant combination?
Distinguish three cases. A value that exists but not with the currently selected other axis stays selectable, and choosing it moves the other axis to a value that pairs with it, announced in a sentence. A value that is out of stock everywhere is rendered struck through with aria-disabled and opens a back-in-stock form. A combination that was never manufactured is not rendered at all.
Why should I avoid the disabled attribute on a variant option?
Because disabled removes the control from the tab order, so a keyboard or screen reader user arrows past it and never learns the value exists. Use aria-disabled with a visually hidden note tied by aria-describedby, keep the control operable, and use the tap to open the back-in-stock form instead of doing nothing.
How do I stop the layout jumping when the variant price changes?
Set font-variant-numeric: tabular-nums on the price so the digits are all the same width, and give any conditional line under the price a min-height equal to its own line height so the row is occupied whether or not it has content. Format the figure with Intl.NumberFormat rather than a template string, and do not animate the number.