Skip to content
WANCRAFT

F.173Forms & Inputs

Variant Picker

Colour swatches and size buttons as radiogroups that derive availability, stock and price deltas from your variants.

NewReactMotionTailwindadvanced

Overview

Variant Picker turns an options + variants data model into accessible product option groups. Colours render as swatches with a selection ring that springs from one swatch to the next; sizes render as buttons with a matching sliding outline. Availability is computed for the current choice of the other options, so combinations that are not made or are sold out are struck through and explained ("XL not made in Sage"). Each value can show its price delta against the cheapest value in its group, the group label reads the chosen value ("Colour: Sage") and previews the hovered one, and a summary row shows the resolved price and a stock hint such as "Only 2 left". Controlled or uncontrolled, with a hidden input carrying the variant id for native forms.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/variant-picker.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/variant-picker.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/variant-picker.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
13 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { VariantPicker, type VariantOption, type ProductVariant } from "@/components/ui/variant-picker";

export function VariantPickerExample() {
  const options: VariantOption[] = [
    { name: "colour", label: "Colour", kind: "swatch", values: [{ value: "sage", label: "Sage", color: "#9CAF88" }, { value: "navy", label: "Navy", color: "#1F2A44" }] },
    { name: "size", label: "Size", kind: "button", values: [{ value: "m", label: "M" }, { value: "l", label: "L" }, { value: "xl", label: "XL" }] },
  ];
  const variants: ProductVariant[] = [
    { id: "sage-m", options: { colour: "sage", size: "m" }, price: 48, stock: 5 },
    { id: "sage-l", options: { colour: "sage", size: "l" }, price: 48, stock: 2 },
    { id: "navy-m", options: { colour: "navy", size: "m" }, price: 48, stock: 0 },
    { id: "navy-xl", options: { colour: "navy", size: "xl" }, price: 52, stock: 8 },
  ];

  return (
    <div className="w-full max-w-sm">
      <VariantPicker
        options={options}
        variants={variants}
        name="variant"
        onChange={(value, variant) => console.log(value, variant?.id)}
      />
    </div>
  );
}

03Props

PropTypeDefaultDescription
optionsVariantOption[]—Required. { name, label?, kind?: "swatch" | "button", values: { value, label?, color? }[] } in display order.
variantsProductVariant[]—Required. { id, options: Record<name, value>, price, stock }; combinations not listed count as "not made".
value / defaultValueRecord<string, string>—Controlled selection, or the initial one (defaults to the first in-stock variant).
onChange(value, variant?: ProductVariant) => void—Called with the new selection and the variant it resolves to.
currencystring"USD"ISO 4217 code used to format prices and deltas.
localestring"en-US"Locale for price formatting (fixed so server and client output match).
showPriceDeltabooleantrueShow the extra cost of each value against the cheapest value in its group (other choices held).
showStockbooleantrueShow the stock hint in the summary row and announce it on change.
lowStocknumber3Stock at or below this number reads "Only N left" in the accent colour.
swatchShape"circle" | "square""circle"Shape of colour swatches and their ring.
swatchSizenumber32Swatch size in px.
spring{ stiffness; damping; mass }{ 520, 36, 0.8 }Spring for the sliding selection ring.
accentstring"#FF7448"Selection ring, focus ring and low-stock dot.
namestring—Adds a hidden input carrying the in-stock variant id for native forms.
labelstring"Product options"Accessible name of the picker group.

04Features

  • 01options + variants data model; availability derived for the current choice of every other option
  • 02Struck-through values with a reason line ("XL not made in Sage", "S sold out in Navy")
  • 03Selection ring that springs between swatches and buttons via shared layout
  • 04Group labels read the chosen value ("Colour: Sage") and preview the hovered value with a mouse
  • 05Per-value price deltas that stay put while choosing, resolved price, "over base" note and a stock hint
  • 06Controlled or uncontrolled; hidden input with the variant id for native forms
motion · springmotion · layoutinput · clickinput · keyboardinput · hover

05Accessibility

  • Each option is a role="radiogroup" named by its label and chosen value; values are role="radio" buttons with aria-checked and a single tab stop (roving tabindex).
  • Arrow keys move and select the next available value, skipping unavailable ones; Home / End jump to the first / last available value.
  • Unavailable values stay readable: aria-disabled="true" plus an aria-describedby reason ("Not made in Sage"); activating one announces the reason instead of selecting.
  • Swatches carry their colour name (and price delta) as an accessible label, so colour is never the only cue.
  • Every change announces the new value, price and stock in a polite live region.
  • prefers-reduced-motion moves the ring instantly and removes the swatch hover scale.

06Limitations

  • Availability is checked against the exact combination of the other current choices; it does not auto-switch other options to find a match.