# Buy box

> A product buy box whose swatches repaint the product: pick a colour and size, and Add to bag throws the product into the bag.

Most product buy boxes are a dropdown for colour, a dropdown for size and a button that flashes a toast. Here the product stands on a pedestal of engraved rings in a stage painted in the colour you pick: choose Olive and the stage floods olive while the rings ripple. Sizes and colours come from your variants; anything not made in that combination or sold out is struck through, the price rolls when a variant costs more or less, a sale shows its saving, and stock says “Only 2 left” when it’s low. Add to bag goes Adding → Added and the product is thrown in an arc into the bag in the corner, which bumps as its count rolls. A sold-out variant turns the button into Notify me, and an email field grows out of it. The catalogue is yours: options and variants in, onAddToCart and onNotify out.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/buy-box.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `input`, `number-roll`, `status-button`, `text-morph`, `stepper-input`
- Docs and live preview: https://hairlineui.com/ui/buy-box
- Registry item (source): https://hairlineui.com/r/buy-box.json

## Usage

```tsx
import { BuyBox } from "@/components/buy-box";

<BuyBox
  name="Linen overshirt"
  options={options}
  variants={variants}
  onAddToCart={(variant, quantity) => api.addToCart(variant.id, quantity)}
  onNotify={(variant, email) => api.notifyWhenBack(variant.id, email)}
/>
```

## With your own API

Map your products into options and variants once, then post the variant id. Throw the server’s message (out of stock since the page loaded) and it shows under the button.

```tsx
"use client";
import { BuyBox, type BuyBoxOption, type BuyBoxVariant } from "@/components/buy-box";

type Product = { title: string; colours: { id: string; name: string; hex: string }[]; sizes: string[]; skus: { id: string; colour: string; size: string; price: number; stock: number }[] };

export function ProductBuy({ product, bagCount }: { product: Product; bagCount: number }) {
  const options: BuyBoxOption[] = [
    { id: "colour", label: "Colour", values: product.colours.map((c) => ({ id: c.id, label: c.name, swatch: c.hex })) },
    { id: "size", label: "Size", values: product.sizes.map((s) => ({ id: s, label: s })) },
  ];
  const variants: BuyBoxVariant[] = product.skus.map((s) => ({ id: s.id, options: { colour: s.colour, size: s.size }, price: s.price, stock: s.stock }));

  return (
    <BuyBox
      name={product.title}
      options={options}
      variants={variants}
      bagCount={bagCount}
      onAddToCart={async (variant, quantity) => {
        const res = await fetch("/api/cart", { method: "POST", body: JSON.stringify({ sku: variant.id, quantity }) });
        if (!res.ok) throw new Error((await res.json()).message);
      }}
      onNotify={(variant, email) => fetch("/api/restock", { method: "POST", body: JSON.stringify({ sku: variant.id, email }) })}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `BuyBoxOption[]` | – | { id, label, values: { id, label, swatch? }[] }. A value with swatch (any CSS colour) is a swatch and paints the stage. |
| `variants` | `BuyBoxVariant[]` | – | { id, options: { [optionId]: valueId }, price, compareAt?, stock?, image? }. stock 0 is sold out; leave it out for plenty. |
| `onAddToCart` | `(variant, quantity) => Promise<unknown>` | – | Add to the bag; throw an Error to show why not. |
| `onNotify` | `(variant, email) => Promise<unknown>` | – | Shows Notify me on sold-out variants and keeps the address. |
| `name / brand` | `string` | – | The product, and a line above it. |
| `icon` | `ReactNode` | – | The product’s glyph on the stage when a variant has no image. |
| `defaultSelection` | `Record<string, string>` | `first in stock` | The picks it opens with. |
| `bagCount / lowStockAt` | `number` | `0 / 5` | What’s in the bag already, and where “Only N left” starts. |
| `currency / locale` | `string` | `"USD" / browser` | Formats the prices. |

## States

- **pick**: The stage in the picked colour with the product on engraved rings, the bag in the corner, name and price, swatches, sizes, stock and a stepper beside Add to bag.
- **colour**: A new swatch floods the stage colour (520ms) and the rings ripple from 0.9× (620ms, a slight overshoot); the swatch’s ring is thrown to it and the label morphs.
- **not made / sold out**: Values not made with the current picks, or sold out, are dimmed and struck through, and say so to screen readers.
- **price**: The price rolls between variants; a sale shows the old price struck through and a −25% badge on the stage.
- **add**: Add to bag → Adding → Added; a copy of the product arcs into the bag (680ms) and the bag bumps as its count rolls.
- **notify**: Sold out shows Notify me; pressing it turns the row into an email field and Notify me, which goes Saving → You’re on the list.
- **error**: If onAddToCart throws, the button shakes into Try again and the message shows under it.

## What it handles

A buy box is usually two selects, a price, a stock note and a button, each figuring out variants on its own. This block works them out from one list of variants and makes the choice visible.

- **Variants worked out for you**: Pick a colour and only sizes made in it stay live; a pick that isn’t made moves the others to the nearest variant that is.
- **The product in its colour**: Swatches paint the stage, or a variant’s image takes its place, so the choice shows before the name does.
- **Price and stock that keep up**: Prices roll, sales show their saving, and stock reads In stock, Only 2 left or Sold out.
- **Added, visibly**: onAddToCart goes Adding → Added, the product flies into the bag and the count rolls.
- **Back-in-stock in place**: Sold out turns the button into Notify me with an email field; onNotify keeps the address.
- **Your catalogue, plain data**: Options, values with swatches and variants with price, compareAt and stock: no product schema to adopt.

## Replaces

a colour picker, a size selector, a variant matcher, a stock badge, an add-to-cart button, a back-in-stock form, a cart counter

## Where it fits

- **Product pages**: The buy box beside the photos on a store’s product page.
- **Quick view**: Choosing a variant from a collection grid without leaving it.
- **Landing page drops**: A single product sold straight from its launch page.
- **Merch stores**: A creator’s shirts and prints in their colours and sizes.

## Notes

- Each option is a radio group named by its label; swatches are named by their colour, and values that aren’t made or are sold out say so.
- Adding and restock notes are announced from a status region; the bag’s count is in its label.
- The flying product is a copy laid over the block and removed when it lands; nothing moves under focus.
- The stage’s colour is your product’s (the swatch), lit from its corner with 1px edges; everything else uses your theme, in light and dark. No drop shadows, glow or blur.
- Built from Input, Stepper input, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion the stage changes colour in place and nothing flies.

License: MIT (https://hairlineui.com/license#components)
