Variant Selector
Multi-dimension product options resolved against a sparse SKU matrix — impossible combinations stay in place struck through, availability converges from either direction, and every dead end names the way out.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/variant-selector.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VariantSelector" component (lucide-react
icons). It resolves several option dimensions against a sparse SKU matrix and is
honest about which combinations a shopper can actually buy.
Contract
- Export a forwardRef div extending HTMLAttributes (omit onChange/defaultValue).
- Types:
VariantSelection = Record<string, string> // dimension id -> option value
VariantOptionStatus = "available" | "sold-out" | "unavailable"
VariantOption { value, label, swatch? } // swatch = any CSS color
VariantDimension { id, label, options: VariantOption[] }
VariantSku { id, options: VariantSelection, inStock: boolean }
- Props: dimensions, skus, value (controlled, may be partial), onValueChange,
onNotify? ((value) => void), label (aria name, default "Options").
- Also export resolveVariantSku(skus, dimensions, selection) -> sku | null:
exact match on every dimension, null while the selection is incomplete or the
combination is not made. Keep it generic so callers get their own SKU type
back and can drive price / stock / photo from it.
- The matrix is sparse on purpose: a combination absent from `skus` is not
manufactured; a combination present with inStock:false is manufactured but out
of stock. Those are different states and the UI must not merge them — only the
second one can ever be restocked.
Behavior
- Per-option status is computed against every OTHER dimension's current pick,
never against a fixed dimension order. That makes availability bidirectional:
"color then size" and "size then color" converge on the same truth table.
Status is "available" if some matching SKU is in stock, "sold-out" if matching
SKUs exist but none is in stock, "unavailable" if none exists at all.
- Unavailable and sold-out options are NEVER hidden or removed. Hiding them
makes the shopper think the size does not exist, and reflows the row on every
pick. They stay in place, struck through and dimmed, with a visually hidden
reason appended to their accessible name ("L, not made with Ruby Red").
- They use aria-disabled, never the native disabled attribute: a keyboard or
screen-reader shopper must still be able to reach the option and hear why it
is out. They stay selectable — landing on one opens the recovery panel instead
of silently doing nothing.
- Each dimension is its own role="radiogroup" labelled by its heading, with
role="radio" + aria-checked children and a roving tabindex (one tab stop per
dimension, on the picked chip, else the first). Arrow keys move and select
with wrap-around; Home/End jump to the ends — including onto aria-disabled
options, deliberately.
- A resolution panel under the dimensions reports the pick in a role="status"
live region: "Still to choose: <names>" while incomplete, "<pick> is in
stock", "<pick> is sold out", or "<pick> isn't a combination we make".
- Dead ends always name a way out: list every single-dimension swap that lands
on an in-stock SKU, each as a button that applies it ("Size: M"). List them
all, never cap the list. If no single swap works, say exactly that and offer a
"Start over" button that clears the selection — never leave the shopper to
probe combinations by hand.
- The restock button renders only when onNotify is supplied AND the pick is a
real-but-sold-out SKU, so it can never be a dead button; the consumer owns the
feedback it produces.
- Under each dimension, when at least one option is blocked, print one muted
line explaining the strike-through in terms of the other picks.
Rendering & styling
- Semantic tokens only for chrome: border-border, bg-background, bg-card,
bg-muted/50, text-muted-foreground, border-primary + bg-primary/10 +
ring-primary for the picked chip, bg-primary / text-primary-foreground for the
restock button, border-destructive/40 + bg-destructive/5 + text-destructive
for the dead-end panel. The single exception is option.swatch: that is product
data (a real garment color), painted verbatim through an inline style on a
decorative dot.
- Unavailable chips get border-dashed on top of the strike-through, so "never
made" reads differently from "sold out" at a glance.
- Chips live in a flex-wrap row with max-w-full and break-words, so a 50-plus
character option label wraps instead of pushing the page sideways at 375px.
- Merge className via cn(); transitions are colour-only and switched off under
prefers-reduced-motion (motion-reduce:transition-none). Focus rings use
focus-visible:ring-2 ring-ring with an offset.
Customization levers
- Presentation: swap the chip for a round swatch button when a dimension is
purely colour — keep the strike-through legible (a diagonal bar) and keep the
hidden reason text.
- Copy: the four resolution sentences, the strike-through hint and the restock
label are the strings to translate first; lift them into a `messages` prop for
i18n.
- Policy: to forbid picking an unavailable option instead of recovering from it,
return early in the click and arrow handlers when status is not "available" —
you then lose the dead-end panel, so keep that only if deep links can never
carry a stale combination.
- Recovery depth: the fix search is single-swap by design (cheap and
explainable). Widen it to two swaps only if your catalogue is sparse enough to
need it.
- Density: gap-5 between dimensions and px-3 py-1.5 chips are the levers for a
compact buy box; the resolution panel can move next to the price instead of
sitting under the options.
- Stock nuance: carry a per-SKU quantity in your own SKU type and render "only 2
left" from the resolved SKU on the consumer side — this component deliberately
only knows buyable versus not buyable.Concepts
- Sparse SKU matrix — the cross product of the dimensions is the space of conceivable combinations;
skusis the much smaller set that actually exists. Three colors × four sizes is twelve cells, and a real catalogue might only manufacture seven of them. - Bidirectional availability — an option's status is filtered by every other dimension's pick, so picking size first converges on exactly the same truth table as picking color first. A one-way "color filters sizes" rule starts lying the moment a shopper works from the other end.
- Disabled, not hidden — removing the impossible options would tell the shopper that size does not exist at all, and would reflow the row on every pick. They stay in place, struck through, carrying their own reason (
not made with Ruby Red) in the accessible name. aria-disabledover nativedisabled— the native attribute drops the element out of the tab order, so a keyboard user never hears why the option is out.aria-disabledkeeps it reachable and announced, and the component decides what a click on it means.- Dead-end recovery — a selection that resolves to nothing buyable is a trap unless the UI names the exit. The component searches every single-dimension swap for one that lands on an in-stock SKU and offers each as a one-click fix; when none exists it says so and offers a reset instead of pretending.
- Restock versus never-made — "sold out" and "we don't make that" look alike and behave completely differently: only the first can be waited for, so only the first gets a notify entry point.
Seat Picker
A zoomable SVG venue map for picking seats — arrow-key navigation across sections, rows and seats, a spoken name on every seat, shape + pattern status encoding, a list fallback, and out-loud refusals for max-seat and orphan-seat rules.
Model Selector
An LLM model picker where every option carries its context window, per-million input/output rate, capability set and whether your key can actually call it — and those fields drive the filtering.