Components

Getting started

Blocks9

Components154

More

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

Used forProduct pagesQuick viewLanding page dropsMerch stores

Install with the shadcn CLI

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

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • 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.

What it handles for you

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.

Instead of wiring upa colour pickera size selectora variant matchera stock badgean add-to-cart buttona back-in-stock forma cart counter
  1. 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.

  2. 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.

  3. Price and stock that keep up

    Prices roll, sales show their saving, and stock reads In stock, Only 2 left or Sold out.

  4. Added, visibly

    onAddToCart goes Adding → Added, the product flies into the bag and the count rolls.

  5. Back-in-stock in place

    Sold out turns the button into Notify me with an email field; onNotify keeps the address.

  6. Your catalogue, plain data

    Options, values with swatches and variants with price, compareAt and stock: no product schema to adopt.

Overview

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.

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.

Usage

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)}/>

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

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.

example.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

PropTypeDescription
optionsBuyBoxOption[]{ id, label, values: { id, label, swatch? }[] }. A value with swatch (any CSS colour) is a swatch and paints the stage.
variantsBuyBoxVariant[]{ 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 / brandstringThe product, and a line above it.
iconReactNodeThe product’s glyph on the stage when a variant has no image.
defaultSelectionRecord<string, string>default first in stockThe picks it opens with.
bagCount / lowStockAtnumberdefault 0 / 5What’s in the bag already, and where “Only N left” starts.
currency / localestringdefault "USD" / browserFormats the prices.

Accessibility and motion

  • 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.

Requirements

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

The CLI also installs Input, Number roll, Status button, Stepper input, Text morph.

Source

components/buy-box.tsx
"use client";import * as React from "react";import { BellRing, ShoppingBag } from "lucide-react";import { Faces } from "./faces";import { Input } from "./input";import { NumberRoll } from "./number-roll";import { StatusButton, type ActionStatus } from "./status-button";import { StepperInput } from "./stepper-input";import { TextMorph } from "./text-morph";import { EASE_THROW, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * BUY BOX: the swatch that repaints the product * *   stage     the product stands on a pedestal of engraved rings *             in a stage painted in the colour you picked; a new *             colour floods the stage and ripples the rings *   options   swatches and sizes from your variants; what isn't *             made or is sold out is struck through; the price *             rolls when the variant costs more or less *   add       Add to bag goes Adding → Added and the product is *             thrown into the bag in the corner, which bumps as *             its count rolls *   sold out  the button becomes Notify me, and an email field *             grows out of it * * The catalogue is yours: pass options and variants (price and * stock per variant), and do the work in onAddToCart and onNotify. * Throw an Error and its message shows under the button. * ───────────────────────────────────────────────────────── */export interface BuyBoxValue {  id: string;  label: string;  /** A CSS colour: makes this a swatch, and paints the stage when picked. */  swatch?: string;}export interface BuyBoxOption {  id: string;  /** e.g. "Colour", "Size". */  label: string;  values: BuyBoxValue[];}export interface BuyBoxVariant {  id: string;  /** The value id picked for each option, e.g. { colour: "sand", size: "m" }. */  options: Record<string, string>;  price: number;  /** The old price, shown struck through. */  compareAt?: number;  /** Left in stock; leave out for plenty, 0 is sold out. */  stock?: number;  /** A photo of this variant; the stage shows `icon` when missing. */  image?: string;}export interface BuyBoxProps {  name: string;  brand?: string;  options: BuyBoxOption[];  variants: BuyBoxVariant[];  /** Starting picks; defaults to the first variant in stock. */  defaultSelection?: Record<string, string>;  /** The product's glyph on the stage when there's no image. */  icon?: React.ReactNode;  /** Items already in the bag. */  bagCount?: number;  /** "Only 3 left" from this many down. */  lowStockAt?: number;  /** Add to the bag; throw an Error to say why not. */  onAddToCart: (variant: BuyBoxVariant, quantity: number) => Promise<unknown>;  /** Shows Notify me on sold-out variants. */  onNotify?: (variant: BuyBoxVariant, email: string) => Promise<unknown>;  currency?: string;  locale?: string;  className?: string;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;const subscribeNothing = () => () => {};/** False on the server and while hydrating: text is formatted in one fixed locale until then, so the server's and the visitor's never disagree. */const useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;const CARD: React.CSSProperties = {  backgroundImage: `linear-gradient(180deg, var(--card), ${mix("var(--card)", 95, "var(--foreground)")})`,  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix("var(--background)", 85)}`,};const LIT: React.CSSProperties = {  backgroundImage: `linear-gradient(180deg, ${mix("var(--primary-foreground)", 16)}, transparent 60%)`,  boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 32)}, inset 0 0 0 1px ${mix("black", 10)}`,};const money = (currency: string, n: number): Intl.NumberFormatOptions => ({ style: "currency", currency, minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 });const matches = (v: BuyBoxVariant, pick: Record<string, string>, skip?: string) => Object.entries(pick).every(([k, val]) => k === skip || v.options[k] === val);/** The pedestal: fine engraved rings the product stands on. */function Rings({ ringsRef }: { ringsRef: React.RefObject<SVGSVGElement | null> }) {  return (    <svg ref={ringsRef} aria-hidden="true" viewBox="0 0 200 80" className="absolute bottom-[6%] left-1/2 w-[78%] -translate-x-1/2 overflow-visible" fill="none" stroke="currentColor">      {Array.from({ length: 7 }, (_, i) => (        <ellipse key={i} cx="100" cy="40" rx={30 + i * 12} ry={8 + i * 3.6} strokeWidth="0.8" strokeOpacity={0.34 - i * 0.04} />      ))}    </svg>  );}export function BuyBox({  name,  brand,  options,  variants,  defaultSelection,  icon,  bagCount = 0,  lowStockAt = 5,  onAddToCart,  onNotify,  currency = "USD",  locale,  className = "",}: BuyBoxProps) {  const reduced = useInteractionReducedMotion();  const hydrated = useHydrated();  const baseId = React.useId();  const [pick, setPick] = React.useState<Record<string, string>>(() => defaultSelection ?? (variants.find((v) => v.stock !== 0) ?? variants[0])?.options ?? {});  const [quantity, setQuantity] = React.useState(1);  const [add, setAdd] = React.useState<{ status: ActionStatus; error?: string }>({ status: "idle" });  const [notify, setNotify] = React.useState<{ open: boolean; email: string; status: ActionStatus; error?: string }>({ open: false, email: "", status: "idle" });  const [added, setAdded] = React.useState(0);  const [ready, setReady] = React.useState(false);  const [said, setSaid] = React.useState("");  const sectionRef = React.useRef<HTMLElement>(null);  const productRef = React.useRef<HTMLDivElement>(null);  const bagRef = React.useRef<HTMLSpanElement>(null);  const ringsRef = React.useRef<SVGSVGElement>(null);  const emailRef = React.useRef<HTMLInputElement>(null);  React.useEffect(() => {    const frame = requestAnimationFrame(() => setReady(true));    return () => cancelAnimationFrame(frame);  }, []);  const variant = variants.find((v) => matches(v, pick) && options.every((o) => v.options[o.id] !== undefined)) ?? null;  const swatchOption = options.find((o) => o.values.some((v) => v.swatch));  const swatch = swatchOption?.values.find((v) => v.id === pick[swatchOption.id])?.swatch ?? "var(--primary)";  const price = variant?.price ?? Math.min(...variants.map((v) => v.price));  const soldOut = variant?.stock === 0;  const low = variant?.stock !== undefined && variant.stock > 0 && variant.stock <= lowStockAt;  const saving = variant?.compareAt && variant.compareAt > variant.price ? Math.round((1 - variant.price / variant.compareAt) * 100) : 0;  const busy = add.status === "pending";  const bag = bagCount + added;  /** Is this value made with the rest of what's picked, and in stock? */  const state = (optionId: string, valueId: string): "ok" | "out" | "none" => {    const made = variants.filter((v) => v.options[optionId] === valueId && matches(v, pick, optionId));    if (!made.length) return "none";    return made.some((v) => v.stock !== 0) ? "ok" : "out";  };  const choose = (optionId: string, valueId: string) => {    if (busy) return;    const next = { ...pick, [optionId]: valueId };    // Not made with the rest of the picks: move the others to the closest variant that is.    const exact = variants.some((v) => matches(v, next));    const fallback = exact ? next : (variants.find((v) => v.options[optionId] === valueId && v.stock !== 0) ?? variants.find((v) => v.options[optionId] === valueId))?.options;    setPick(fallback ?? next);    setAdd({ status: "idle" });    if (notify.open) setNotify((n) => ({ ...n, open: false, status: "idle", error: undefined }));    if (optionId === swatchOption?.id && !reduced && valueId !== pick[optionId]) {      // The rings are centred with `translate`, so the ripple only scales.      ringsRef.current?.animate([{ transform: "scale(0.9)", opacity: 0.35 }, { transform: "scale(1)", opacity: 1 }], { duration: 620, easing: EASE_THROW });    }  };  // The product is thrown into the bag: a copy arcs from the stage to the bag, which bumps as it lands.  const fly = () => {    const section = sectionRef.current;    const from = productRef.current;    const to = bagRef.current;    if (!section || !from || !to || reduced) {      setAdded((n) => n + quantity);      return;    }    const box = section.getBoundingClientRect();    const a = from.getBoundingClientRect();    const b = to.getBoundingClientRect();    const copy = from.cloneNode(true) as HTMLElement;    copy.setAttribute("aria-hidden", "true");    Object.assign(copy.style, { position: "absolute", left: `${a.left - box.left}px`, top: `${a.top - box.top}px`, width: `${a.width}px`, height: `${a.height}px`, margin: "0", zIndex: "20", pointerEvents: "none", willChange: "transform" });    section.appendChild(copy);    const dx = b.left + b.width / 2 - (a.left + a.width / 2);    const dy = b.top + b.height / 2 - (a.top + a.height / 2);    const s = Math.min(b.height / a.height, 0.3);    const flight = copy.animate(      [        { transform: "translate(0,0) scale(1)", opacity: 1 },        { transform: `translate(${dx * 0.55}px, ${dy * 0.35 - 40}px) scale(${(1 + s) / 2}) rotate(-8deg)`, opacity: 1, offset: 0.5 },        { transform: `translate(${dx}px, ${dy}px) scale(${s}) rotate(-14deg)`, opacity: 0.2 },      ],      { duration: 680, easing: "cubic-bezier(0.5,0,0.25,1)", fill: "forwards" },    );    flight.onfinish = flight.oncancel = () => {      copy.remove();      setAdded((n) => n + quantity);      bagRef.current?.animate([{ transform: "scale(1)" }, { transform: "scale(1.16)" }, { transform: "scale(1)" }], { duration: 420, easing: EASE_THROW });    };  };  const addToBag = async () => {    if (!variant || busy || soldOut) return;    setAdd({ status: "pending" });    try {      await onAddToCart(variant, quantity);      setAdd({ status: "success" });      setSaid(`Added ${quantity} ${name} to your bag.`);      fly();    } catch (e) {      setAdd({ status: "error", error: e instanceof Error && e.message ? e.message : "Couldn’t add that. Try again." });    }  };  const sendNotify = async () => {    if (!variant || !onNotify) return;    if (!notify.open) {      setNotify((n) => ({ ...n, open: true }));      window.setTimeout(() => emailRef.current?.focus({ preventScroll: true }), reduced ? 0 : 300);      return;    }    if (!EMAIL.test(notify.email.trim())) return setNotify((n) => ({ ...n, status: "idle", error: "Add an email we can write to" }));    setNotify((n) => ({ ...n, status: "pending", error: undefined }));    try {      await onNotify(variant, notify.email.trim());      setNotify((n) => ({ ...n, status: "success" }));      setSaid("We’ll email you when it’s back.");    } catch (e) {      setNotify((n) => ({ ...n, status: "error", error: e instanceof Error && e.message ? e.message : "Couldn’t save that. Try again." }));    }  };  const stock = soldOut ? "Sold out" : low ? `Only ${variant?.stock} left` : variant ? "In stock, ships today" : "Pick an option";  const fmt = (n: number) => money(currency, n);  return (    <section ref={sectionRef} aria-labelledby={`${baseId}-name`} className={`relative w-full max-w-[400px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}      <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 400px)" }} />      <div className="@container w-full">        {/* The one lit surface: the stage, painted in the colour you picked. */}        <div          className="relative h-[220px] overflow-clip rounded-[22px]"          style={{            backgroundColor: mix(swatch, 48, "var(--card)"),            backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix("white", 34)} 0%, transparent 55%), linear-gradient(175deg, transparent 45%, ${mix("black", 14)} 100%)`,            boxShadow: `inset 0 1px 0 ${mix("white", 40)}, inset 0 0 0 1px ${mix(swatch, 30)}`,            color: mix(swatch, 62, "black"),            transition: reduced ? "none" : `background-color 520ms ${EASE}, color 520ms ${EASE}, box-shadow 520ms ${EASE}`,          }}        >          <Rings ringsRef={ringsRef} />          <div ref={productRef} className="absolute inset-x-0 top-[14%] mx-auto flex size-[128px] items-center justify-center [&_svg]:size-[92px]">            {variant?.image ? (              // eslint-disable-next-line @next/next/no-img-element              <img key={variant.image} src={variant.image} alt="" decoding="async" className="size-full object-contain motion-safe:animate-[ui-fade-in_420ms_ease-out_both]" />            ) : (              <span aria-hidden="true" className="flex [&_svg]:stroke-[1.2]">                {icon ?? <ShoppingBag />}              </span>            )}          </div>          {saving > 0 && (            <span className="absolute left-3 top-3 rounded-full bg-foreground px-2 py-1 text-[11.5px] font-medium tabular-nums text-background">              −<NumberRoll value={saving} duration={600} />%            </span>          )}          <span ref={bagRef} className="absolute right-3 top-3 inline-flex h-8 items-center gap-1.5 rounded-full bg-card px-3 text-[12.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)]" aria-label={`${bag} in your bag`}>            <ShoppingBag aria-hidden="true" className="size-3.5" />            <NumberRoll value={bag} duration={600} aria-hidden="true" />          </span>        </div>        <div className="px-2 pb-1 pt-3">          <div className="flex items-start justify-between gap-3">            <div className="min-w-0">              {brand && <p className="text-[12px] text-muted-foreground">{brand}</p>}              <h2 id={`${baseId}-name`} className="text-[18px] font-medium leading-tight tracking-[-0.02em]">                {name}              </h2>            </div>            <div className="shrink-0 text-right">              <p className="text-[22px] font-medium leading-none tracking-[-0.03em] tabular-nums">                <NumberRoll value={price} locales={locale} format={fmt(price)} duration={800} />              </p>              {saving > 0 && variant?.compareAt && (                <p className="mt-1 text-[12px] text-muted-foreground tabular-nums line-through">{new Intl.NumberFormat(locale ?? (hydrated ? undefined : "en-US"), fmt(variant.compareAt)).format(variant.compareAt)}</p>              )}            </div>          </div>          {options.map((o) => {            const chosen = o.values.find((v) => v.id === pick[o.id]);            return (              <div key={o.id} className="mt-3.5">                <p className="text-[12.5px] text-muted-foreground">                  {o.label} · <TextMorph className="text-foreground">{chosen?.label ?? "Choose"}</TextMorph>                </p>                <div role="radiogroup" aria-label={o.label} className="mt-2 flex flex-wrap gap-1.5">                  {o.values.map((v) => {                    const on = v.id === pick[o.id];                    const st = state(o.id, v.id);                    const label = `${v.label}${st === "out" ? ", sold out" : st === "none" ? ", not made in this combination" : ""}`;                    return v.swatch ? (                      <button                        key={v.id}                        type="button"                        role="radio"                        aria-checked={on}                        aria-label={label}                        title={v.label}                        onClick={() => choose(o.id, v.id)}                        className={`relative size-8 cursor-pointer rounded-full ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}                        style={{                          background: v.swatch,                          boxShadow: on                            ? `inset 0 0 0 1px ${mix("black", 14)}, 0 0 0 2px var(--card), 0 0 0 3.5px var(--primary)`                            : `inset 0 0 0 1px ${mix("black", 14)}, 0 0 0 2px var(--card), 0 0 0 2px transparent`,                          opacity: st === "ok" ? 1 : 0.45,                          transition: reduced ? "none" : `box-shadow 280ms ${EASE_THROW}, opacity 200ms`,                        }}                      >                        {st !== "ok" && <span aria-hidden="true" className="absolute left-1/2 top-1/2 h-px w-[130%] -translate-x-1/2 -translate-y-1/2 -rotate-45 bg-foreground/60" />}                      </button>                    ) : (                      <button                        key={v.id}                        type="button"                        role="radio"                        aria-checked={on}                        aria-label={label}                        onClick={() => choose(o.id, v.id)}                        className={`h-9 min-w-11 cursor-pointer rounded-full px-3 text-[13px] tabular-nums ${INTERACTION_PRESS} ${INTERACTION_FOCUS} ${                          on ? "bg-primary/[0.08] font-medium text-primary shadow-[inset_0_0_0_1.5px_var(--primary)]" : "text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent"                        } ${st !== "ok" ? "text-muted-foreground line-through decoration-1" : ""}`}                      >                        {v.label}                      </button>                    );                  })}                </div>              </div>            );          })}          <p className={`mt-3.5 flex items-center gap-1.5 text-[12.5px] ${soldOut ? "text-red-600 dark:text-red-400" : low ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"}`}>            <span aria-hidden="true" className={`size-1.5 rounded-full ${soldOut ? "bg-red-500" : low ? "bg-amber-500" : "bg-emerald-500"}`} />            <TextMorph>{stock}</TextMorph>          </p>          <div className="mt-2.5">            <Faces active={notify.open ? 1 : 0} reduced={reduced} ready={ready}>              <div className="flex items-center gap-2">                <StepperInput label="Quantity" value={quantity} min={1} max={variant?.stock && variant.stock > 0 ? variant.stock : 10} onValueChange={setQuantity} disabled={soldOut || busy} />                {soldOut && onNotify ? (                  <button type="button" onClick={() => void sendNotify()} className={`inline-flex h-11 flex-1 cursor-pointer items-center justify-center gap-2 rounded-full text-[14.5px] font-medium text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>                    <BellRing aria-hidden="true" className="size-4" />                    Notify me                  </button>                ) : (                  <StatusButton                    status={add.status}                    labels={{ idle: soldOut ? "Sold out" : "Add to bag", pending: "Adding", success: "Added", error: "Try again" }}                    icon={<ShoppingBag aria-hidden="true" />}                    disabled={!variant || soldOut}                    onReset={() => setAdd({ status: "idle" })}                    resetAfter={1500}                    onClick={() => void addToBag()}                    className="h-11 flex-1 text-[14.5px] disabled:opacity-45"                    style={add.status === "error" ? undefined : LIT}                  />                )}              </div>              <form                className="flex items-start gap-2"                onSubmit={(e) => {                  e.preventDefault();                  void sendNotify();                }}              >                <Input                  ref={emailRef}                  aria-label="Email for a restock note"                  type="email"                  inputMode="email"                  autoComplete="email"                  placeholder="you@email.com"                  value={notify.email}                  error={notify.error}                  disabled={notify.status === "pending" || notify.status === "success"}                  onValueChange={(email) => setNotify((n) => ({ ...n, email, error: undefined, status: n.status === "error" ? "idle" : n.status }))}                  className="flex-1"                />                <StatusButton type="submit" status={notify.status} labels={{ idle: "Notify me", pending: "Saving", success: "You’re on the list", error: "Try again" }} className="h-9 shrink-0 px-4 text-[13px]" style={notify.status === "error" ? undefined : LIT} />              </form>            </Faces>            {add.error && (              <p role="alert" className="mt-1.5 text-[12px] text-red-600 dark:text-red-400">                {add.error}              </p>            )}          </div>        </div>        <span className="sr-only" role="status">          {said}        </span>      </div>    </section>  );}export default BuyBox;
components/faces.tsx
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FACES: the states of one surface, in the same place * * The active face is in flow; the others are laid over it, faded * and nudged aside (faces before it to the left, faces after it to * the right), so switching reads as the surface turning to its * next side. The height eases between the faces' measured heights, * so nothing around it jumps. Used by the blocks (the wallet's * activity and keypad, a pricing card and its checkout, a booking's * times, details and confirmation). * ───────────────────────────────────────────────────────── */const EASE = "cubic-bezier(0.16,1,0.3,1)";export const FACES_MORPH = 460;export function Faces({  active,  reduced,  ready,  className = "",  children,}: {  active: number;  reduced: boolean;  /** False until the first paint, so the faces don't animate into place on mount. */  ready: boolean;  className?: string;  children: React.ReactNode[];}) {  const refs = React.useRef<(HTMLDivElement | null)[]>([]);  const [heights, setHeights] = React.useState<number[] | null>(null);  const count = children.length;  React.useLayoutEffect(() => {    const els = refs.current.slice(0, count);    const observer = new ResizeObserver(() => {      const next = els.map((el) => el?.offsetHeight ?? 0);      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));    });    els.forEach((el) => el && observer.observe(el));    return () => observer.disconnect();  }, [count]);  const face = (i: number): React.CSSProperties => {    const on = i === active;    return {      opacity: on ? 1 : 0,      transform: on || reduced ? "none" : `translateX(${i < active ? -18 : 18}px)`,      filter: on || reduced ? "none" : "blur(4px)",      transition: !ready        ? "none"        : reduced          ? "opacity 160ms ease-out"          : on            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,    };  };  return (    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : "none" }}>      {children.map((child, i) => (        <div          key={i}          ref={(el) => {            refs.current[i] = el;          }}          inert={active !== i || undefined}          aria-hidden={active !== i || undefined}          className={active === i ? "relative" : "pointer-events-none absolute inset-x-0 top-0"}          style={face(i)}        >          {child}        </div>      ))}    </div>  );}
components/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';/** * The press every button primitive shares: a 3% give that eases in and settles back. * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list * or the press snaps. */export const INTERACTION_PRESS =  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';export const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {  const d = dimension || 320;  const abs = Math.abs(overshoot);  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;}/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */export function projectInertia(distance: number, velocity: number, time = 0.12): number {  return distance + velocity * 1000 * time;}/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {  const maxAllowed = Math.abs(distanceToTarget) * factor;  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);}/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */export function springFrames(  from: number,  to: number,  velocity = 0,  duration = 480,  config: { omega?: number; damping?: number; steps?: number } = {},) {  const { omega = 16, damping = 0.74, steps = 36 } = config;  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: steps + 1 }, (_, i) => {    const t = (i / steps) * (duration / 1000);    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === steps ? to : value, offset: i / steps };  });}

Registry item: /r/buy-box.json