Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Bell, Check, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/** One pick per dimension id — `{ color: "ruby", size: "l" }`. */
export type VariantSelection = Record<string, string>

/**
 * Per-option availability, always computed **against the other dimensions'
 * current picks**:
 * - `available`   — at least one in-stock SKU has this option plus every other pick
 * - `sold-out`    — that combination is manufactured but every match is out of stock

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/variant-selector.json

Prompt

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; skus is 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-disabled over native disabled — the native attribute drops the element out of the tab order, so a keyboard user never hears why the option is out. aria-disabled keeps 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.

On This Page