# Variant Picker

> Product variant picker for React and shadcn: colour swatches and sizes on Base UI radio groups, with sold-out sizes struck through.

Colour and size for one product: the ring glides between swatches, sold-out sizes strike with a wipe, and the price rolls when the variant's price changes.

- Tier: Free (MIT)
- Interaction: Select
- Page: https://eased.dev/components/variant-picker
- Installs: motion, @base-ui/react
- Registry: https://eased.dev/r/variant-picker.json

## Install

```bash
npx shadcn add https://eased.dev/r/variant-picker.json
```

Colour and size for one product, on Base UI radio groups, with the price and stock line above and below.

## When to use

- A product page's buy box: pick a colourway, then a size, before Add to bag.
- Any variant matrix where stock differs per combination and shoppers should see it before they choose.
- Over a Select: swatches and sizes are visible at once, and sold-out options read at a glance.

## When not to use

- More than two option axes (colour, size and length): compose two pickers or use Select for the third.
- Choosing from a grid of products: use Product Card, whose quick add has its own size row.

## Usage

```tsx example.tsx
import { VariantPicker } from "@/components/eased/variant-picker/variant-picker";

export function BuyBox() {
  const [id, setId] = useState("p1-0-S");
  return (
    <VariantPicker
      value={id}
      onValueChange={setId}
      variants={[
        { id: "p1-0-S", color: "Moss", swatch: "#55663f", size: "S", stock: 12, price: 248, compareAt: 310 },
        { id: "p1-0-M", color: "Moss", swatch: "#55663f", size: "M", stock: 0, price: 248, compareAt: 310 },
        { id: "p1-2-S", color: "Clay", swatch: "#a8623f", size: "S", stock: 2, price: 198, compareAt: 310 },
      ]}
    />
  );
}
```

## API reference

### VariantPicker

| Prop | Type | Default | Description |
|---|---|---|---|
| `variants` | `ProductVariant[]` | | `{ id, color, swatch, size, stock, price, compareAt? }`. Colours and sizes are read in order. |
| `value` / `defaultValue` | `string` | first in stock | The selected variant id. |
| `onValueChange` | `(id, variant) => void` | | |
| `showPrice` | `boolean` | `true` | The price line with its markdown badge. |
| `lowStockAt` | `number` | `3` | At or under this, the stock line reads "Only n left". |
| `sizeGuide` | `ReactNode` | | Trailing content on the size label row. |
| `inStockLabel` | `string` | `"In stock, ships tomorrow"` | |
| `currency` / `locale` | `string` / `Intl.LocalesArgument` | `"USD"` / `"en-US"` | |

Picking a colour keeps the size if that colour comes in it, otherwise it moves to the colour's first size in stock. Sold-out sizes stay selectable so the shopper can ask to be notified.

## Keyboard

| Key | Action |
|---|---|
| `Tab` | Moves between the colour group and the size group (one stop each). |
| Arrows | Move and select within a group. |

## Accessibility

- Each group is a `radiogroup` named by its visible label; sold-out options say so in their name.
- The stock line is a polite live region, so a change of size announces "Only 2 left in Clay, S".

## Motion

The swatch ring and the size outline are shared-layout indicators on the snappy spring, interruptible mid-glide. A sold-out size's line wipes in from the left over 280ms and wipes back out to the right in 180ms. The price rolls its digits when the variant's price differs; the colour name and stock line swap with blur. Reduced motion: indicators jump and lines fade.

## Notes for AI

Pick it for the product page's buy box. Feed it every variant with live stock; don't pre-filter sold-out ones, the picker shows them struck.

Don't build your own price or stock text next to it: it renders both. Pair it with one primary Add to bag button that changes to "Notify me" when the selected variant has no stock.

## Related

- [Product Card](/components/product-card): quick add from a grid.
- [Cart Drawer](/components/cart-drawer): where the variant lands.

## Example

`components/eased/variant-picker/variant-picker-demo.tsx`

```tsx
"use client";

import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { ProductArt } from "../_primitives/commerce-core/commerce-core";
import { productById, productVariants } from "../_primitives/fixtures/ecommerce";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { enter, exit, reducedFade, useSafeReducedMotion } from "../lib/motion";
import { VariantPicker, type PickerVariant } from "./variant-picker";

const jacket = productById("p1");
const VARIANTS: PickerVariant[] = productVariants(jacket);

/** Fern & Field's down jacket: Clay is the marked-down colourway with thin stock; Moss is out in M. */
export function VariantPickerDemo() {
  const reduced = useSafeReducedMotion();
  const [id, setId] = useState("p1-0-S");
  const v = VARIANTS.find((x) => x.id === id)!;
  const out = v.stock <= 0;

  return (
    <DemoFrame>
      <div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-(--pad)">
        <div className="flex items-center gap-3">
          <span className="relative grid size-14 shrink-0 overflow-hidden rounded-control *:col-start-1 *:row-start-1">
            <AnimatePresence initial={false}>
              <motion.span
                key={v.color}
                className="grid"
                initial={{ opacity: 0, filter: reduced ? "none" : "blur(4px)" }}
                animate={{ opacity: 1, filter: "blur(0px)", transition: reduced ? reducedFade : { duration: 0.3, ease: enter.ease } }}
                exit={{ opacity: 0, transition: reduced ? reducedFade : { ...exit, delay: 0.1 } }}
              >
                <ProductArt kind={jacket.art} color={v.swatch} label={`${jacket.name} in ${v.color}`} className="size-14 rounded-control" />
              </motion.span>
            </AnimatePresence>
          </span>
          <div className="min-w-0">
            <p className="truncate text-ui font-medium text-text">{jacket.name}</p>
            <p className="truncate text-ui-sm text-text-muted">{jacket.blurb}</p>
          </div>
        </div>
        <VariantPicker
          variants={VARIANTS}
          value={id}
          onValueChange={setId}
          sizeGuide={
            <Button variant="ghost" size="sm" edge="end" hitArea={false} className="h-6!">
              Size guide
            </Button>
          }
        />
        <Button variant={out ? "secondary" : "primary"} size="lg" className="mt-auto w-full shrink-0">
          {out ? "Notify me when it’s back" : "Add to bag"}
        </Button>
      </div>
    </DemoFrame>
  );
}
```
