{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "variant-swatches",
  "title": "Variant Swatches",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Colour swatches and a ruled size grid that work out stock from your variants: with a colour picked, sizes it has none of are struck through with a diagonal rule and read out as sold out, yet stay focusable and choosable — for a notify-me — while a live status line says why they can't be bought. Low stock shows under the size and in the status line. The selection mark slides between options with Motion (a fade under reduced motion). A radio group with roving focus; compound parts share the selection, the resolved variant and whether it can be bought through useVariantSwatches().",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/variant-swatches/variant-swatches.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AnimatePresence, MotionConfig, motion, useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/*\n * The variant picker on a product page: colour swatches, a size grid, and a\n * line that says what is left.\n *\n *   <VariantSwatches options={options} variants={variants} defaultValue={{ Colour: \"Tan\" }} />\n *\n *   <VariantSwatches options={options} variants={variants}>      or composed\n *     <VariantSwatchesOption name=\"Colour\" />\n *     <VariantSwatchesOption name=\"Size\" action={<SizeGuideDialog … />} />\n *     <VariantSwatchesStatus />\n *   </VariantSwatches>\n *\n * Stock is worked out per option from the variants you pass: with Tan\n * picked, a size Tan has none of reads as sold out, and a size with two\n * pairs left says so. A sold-out option is struck through with a diagonal\n * rule and read out as sold out, but it can still be chosen — a shopper who\n * wants that size can ask to be told when it's back — and the status line\n * says why it can't be bought. The selection mark slides between options\n * with Motion; under reduced motion it fades. Parts share the selection and\n * the variant it points at through context; `useVariantSwatches()` reads it.\n */\n\nexport interface VariantValue {\n  value: string\n  /** Shown text, when it differs from `value`. */\n  label?: string\n  /** A CSS background — `\"#a8764a\"`, or `\"url(/linen.jpg) center / cover\"`. */\n  swatch?: string\n}\n\nexport interface VariantOption {\n  /** \"Colour\", \"Size\" — also the key in the selection and in each variant. */\n  name: string\n  values: (string | VariantValue)[]\n  /** Defaults to \"swatch\" when any value has a swatch, \"grid\" otherwise. */\n  display?: \"swatch\" | \"grid\"\n}\n\nexport interface Variant {\n  id: string\n  /** One value per option: `{ Colour: \"Tan\", Size: \"41\" }`. */\n  options: Record<string, string>\n  /** Units on hand. 0 is sold out; leave it out when you don't track stock. */\n  stock?: number\n}\n\n/** The shopper's picks, by option name. Options not yet chosen are absent. */\nexport type VariantSelection = Record<string, string>\n\nexport type VariantAvailability =\n  | { kind: \"available\" }\n  | { kind: \"low\"; stock: number }\n  | { kind: \"sold-out\" }\n  /** No variant has this combination at all. */\n  | { kind: \"unavailable\" }\n\nexport interface VariantSwatchesMessages {\n  /** Read out after a sold-out option's name. */\n  soldOutLabel: string\n  /** Read out after the name of a combination that isn't made. */\n  unavailableLabel: string\n  /** Under a size in the grid, when few are left: \"2 left\". */\n  stockLeft: (stock: number) => string\n  /** The status line, for the chosen variant. */\n  lowStock: (stock: number) => string\n  /** \"41 is sold out in Tan.\" `others` are the other picks, as labels. */\n  soldOut: (value: string, others: string[]) => string\n  unavailable: (value: string, others: string[]) => string\n}\n\nconst MESSAGES: VariantSwatchesMessages = {\n  soldOutLabel: \"sold out\",\n  unavailableLabel: \"not available\",\n  stockLeft: (stock) => `${stock} left`,\n  lowStock: (stock) => `Only ${stock} left.`,\n  soldOut: (value, others) =>\n    `${value} is sold out${others.length ? ` in ${others.join(\", \")}` : \"\"}.`,\n  unavailable: (value, others) =>\n    `${value} isn't made${others.length ? ` in ${others.join(\", \")}` : \"\"}.`,\n}\n\n/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */\nconst MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const\n\nconst AVAILABLE: VariantAvailability = { kind: \"available\" }\n\n/** A value with its label filled in, as the parts see it. */\nexport interface ResolvedVariantValue {\n  value: string\n  label: string\n  swatch?: string\n}\n\n/** An option with its display settled, as the parts see it. */\nexport interface ResolvedVariantOption {\n  name: string\n  display: \"swatch\" | \"grid\"\n  values: ResolvedVariantValue[]\n}\n\nfunction normalize(options: VariantOption[]): ResolvedVariantOption[] {\n  return options.map((option) => {\n    const values = option.values.map((v) =>\n      typeof v === \"string\" ? { value: v, label: v } : { ...v, label: v.label ?? v.value }\n    )\n    return {\n      name: option.name,\n      display: option.display ?? (values.some((v) => v.swatch) ? \"swatch\" : \"grid\"),\n      values,\n    }\n  })\n}\n\nfunction useControllableState<T>(\n  value: T | undefined,\n  defaultValue: T,\n  onChange?: (next: T) => void\n) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n  const controlled = value !== undefined\n  const set = React.useCallback(\n    (next: T) => {\n      if (!controlled) setUncontrolled(next)\n      onChange?.(next)\n    },\n    [controlled, onChange]\n  )\n  return [controlled ? value : uncontrolled, set] as const\n}\n\n/* ─── context ──────────────────────────────────────────────────────────── */\n\nexport interface VariantSwatchesContextValue {\n  options: ResolvedVariantOption[]\n  /** The selection, with any one-value option (\"One size\") filled in. */\n  value: VariantSelection\n  /** Picks a value — a sold-out one too: `purchasable` and the status say so. */\n  select: (name: string, value: string) => void\n  /** Stock for one option's value, given the shopper's other picks. */\n  availability: (name: string, value: string) => VariantAvailability\n  /** The variant every option points at, once each has a pick. */\n  variant: Variant | undefined\n  /** False until every option is picked, while stock loads, and while sold out. */\n  purchasable: boolean\n  /** What the status line says right now, or null. */\n  status: string | null\n  messages: VariantSwatchesMessages\n  loading: boolean\n}\n\nconst VariantSwatchesContext = React.createContext<VariantSwatchesContextValue | null>(null)\n\nexport function useVariantSwatches() {\n  const context = React.useContext(VariantSwatchesContext)\n  if (!context) {\n    throw new Error(\"useVariantSwatches must be used within <VariantSwatches>.\")\n  }\n  return context\n}\n\n/* ─── root ─────────────────────────────────────────────────────────────── */\n\nexport interface VariantSwatchesProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  options: VariantOption[]\n  /** Leave out to offer every combination, with no stock shown. */\n  variants?: Variant[]\n  value?: VariantSelection\n  defaultValue?: VariantSelection\n  onValueChange?: (value: VariantSelection) => void\n  /** The variant the picks now point at — undefined until each option has one. */\n  onVariantChange?: (variant: Variant | undefined) => void\n  /** At or below this many, stock is shown: \"Only 2 left.\" Defaults to 3. */\n  lowStockThreshold?: number\n  /** Rendered beside the first size grid's label — a `<SizeGuideDialog>`, say. */\n  sizeGuide?: React.ReactNode\n  messages?: Partial<VariantSwatchesMessages>\n  /** Stock still on its way: placeholders in the shape of the options. */\n  loading?: boolean\n}\n\nexport function VariantSwatches({\n  options: optionsProp,\n  variants,\n  value: valueProp,\n  defaultValue = {},\n  onValueChange,\n  onVariantChange,\n  lowStockThreshold = 3,\n  sizeGuide,\n  messages: messagesProp,\n  loading = false,\n  className,\n  children,\n  ...props\n}: VariantSwatchesProps) {\n  const [picked, setPicked] = useControllableState(valueProp, defaultValue, onValueChange)\n  const options = React.useMemo(() => normalize(optionsProp), [optionsProp])\n  const messages = React.useMemo(() => ({ ...MESSAGES, ...messagesProp }), [messagesProp])\n\n  const value = React.useMemo(() => {\n    const filled: VariantSelection = {}\n    for (const option of options) {\n      if (option.values.length === 1) filled[option.name] = option.values[0].value\n    }\n    return { ...filled, ...picked }\n  }, [options, picked])\n\n  const availability = React.useCallback(\n    (name: string, candidate: string): VariantAvailability => {\n      if (!variants) return AVAILABLE\n      let exists = false\n      let untracked = false\n      let stock = 0\n      for (const variant of variants) {\n        if (variant.options[name] !== candidate) continue\n        const fits = options.every(\n          (o) =>\n            o.name === name ||\n            value[o.name] === undefined ||\n            variant.options[o.name] === value[o.name]\n        )\n        if (!fits) continue\n        exists = true\n        if (variant.stock === undefined) untracked = true\n        else stock += Math.max(0, variant.stock)\n      }\n      if (!exists) return { kind: \"unavailable\" }\n      if (untracked) return AVAILABLE\n      if (stock === 0) return { kind: \"sold-out\" }\n      if (stock <= lowStockThreshold) return { kind: \"low\", stock }\n      return AVAILABLE\n    },\n    [variants, options, value, lowStockThreshold]\n  )\n\n  const find = React.useCallback(\n    (selection: VariantSelection) =>\n      options.every((o) => selection[o.name] !== undefined)\n        ? variants?.find((v) => options.every((o) => v.options[o.name] === selection[o.name]))\n        : undefined,\n    [options, variants]\n  )\n\n  const variant = find(value)\n  const complete = options.every((o) => value[o.name] !== undefined)\n  const purchasable =\n    !loading && complete && (!variants || (variant !== undefined && variant.stock !== 0))\n\n  const select = React.useCallback(\n    (name: string, next: string) => {\n      if (value[name] === next) return\n      setPicked({ ...picked, [name]: next })\n      onVariantChange?.(find({ ...value, [name]: next }))\n    },\n    [value, picked, setPicked, onVariantChange, find]\n  )\n\n  const status = React.useMemo(() => {\n    const labelOf = (name: string, v: string) =>\n      options.find((o) => o.name === name)?.values.find((x) => x.value === v)?.label ?? v\n    const othersThan = (name: string) =>\n      options\n        .filter((o) => o.name !== name && value[o.name] !== undefined && o.values.length > 1)\n        .map((o) => labelOf(o.name, value[o.name]))\n\n    if (!variants) return null\n    if (!complete) {\n      // Part-way through: a pick that can't be had whatever comes next.\n      for (const option of options) {\n        const picked = value[option.name]\n        if (picked === undefined || option.values.length < 2) continue\n        const state = availability(option.name, picked)\n        const label = labelOf(option.name, picked)\n        if (state.kind === \"sold-out\") return messages.soldOut(label, othersThan(option.name))\n        if (state.kind === \"unavailable\") return messages.unavailable(label, othersThan(option.name))\n      }\n      return null\n    }\n    // Explained by the last option — the size, usually — \"in\" the others.\n    const last = [...options].reverse().find((o) => o.values.length > 1) ?? options[options.length - 1]\n    const label = labelOf(last.name, value[last.name])\n    if (!variant) return messages.unavailable(label, othersThan(last.name))\n    if (variant.stock === 0) return messages.soldOut(label, othersThan(last.name))\n    if (variant.stock !== undefined && variant.stock <= lowStockThreshold) {\n      return messages.lowStock(variant.stock)\n    }\n    return null\n  }, [availability, complete, variants, variant, options, value, messages, lowStockThreshold])\n\n  const context = React.useMemo<VariantSwatchesContextValue>(\n    () => ({\n      options,\n      value,\n      select,\n      availability,\n      variant,\n      purchasable,\n      status,\n      messages,\n      loading,\n    }),\n    [options, value, select, availability, variant, purchasable, status, messages, loading]\n  )\n\n  // A product with nothing to choose: there is no picker to show.\n  if (!children && !options.some((o) => o.values.length > 0)) return null\n  const firstGrid = options.find((o) => o.display === \"grid\" && o.values.length > 1)\n\n  return (\n    <VariantSwatchesContext.Provider value={context}>\n      <MotionConfig reducedMotion=\"user\">\n        <div\n          data-slot=\"variant-swatches\"\n          aria-busy={loading || undefined}\n          className={cn(\"flex flex-col\", className)}\n          {...props}\n        >\n          {children ?? (\n            <>\n              <div className=\"flex flex-col gap-7\">\n                {options.map((option) => (\n                  <VariantSwatchesOption\n                    key={option.name}\n                    name={option.name}\n                    action={option === firstGrid ? sizeGuide : undefined}\n                  />\n                ))}\n              </div>\n              <VariantSwatchesStatus />\n            </>\n          )}\n        </div>\n      </MotionConfig>\n    </VariantSwatchesContext.Provider>\n  )\n}\n\n/* ─── parts ────────────────────────────────────────────────────────────── */\n\nexport interface VariantSwatchesOptionProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Which option: matches a `name` in `options`. */\n  name: string\n  /** Beside the label, on the right — a size guide link. */\n  action?: React.ReactNode\n  /** Grid columns. Defaults to one row up to six sizes, then even rows. */\n  columns?: number\n}\n\n/**\n * One option: its label, the value picked (or the one under the pointer or\n * focus), and the swatches or grid. A radio group — Tab enters on the pick,\n * the arrow keys move, Space or Enter chooses. Moving never chooses, so\n * arrowing past a colour doesn't swap the product photo on the way.\n */\nexport function VariantSwatchesOption({\n  name,\n  action,\n  columns,\n  className,\n  ...props\n}: VariantSwatchesOptionProps) {\n  const { options, value, loading } = useVariantSwatches()\n  const option = options.find((o) => o.name === name)\n  const id = React.useId()\n  const [preview, setPreview] = React.useState<string | null>(null)\n\n  if (!option || option.values.length === 0) return null\n\n  const selected = value[name]\n  const shown = option.values.find((v) => v.value === (preview ?? selected))?.label\n\n  return (\n    <div\n      data-slot=\"variant-swatches-option\"\n      data-option={name}\n      data-display={option.display}\n      className={cn(\"min-w-0\", className)}\n      {...props}\n    >\n      <div className=\"mb-3 flex min-h-5 items-baseline justify-between gap-4\">\n        <p className=\"text-xs font-medium tracking-[0.14em] uppercase\">\n          <span id={`${id}-label`}>{option.name}</span>\n          {shown && !loading ? (\n            <span\n              // A sighted echo of the checked radio — except for a one-value\n              // option (\"One size\"), which has no radio and is read from here.\n              aria-hidden={option.values.length > 1 || undefined}\n              data-slot=\"variant-swatches-value\"\n              className=\"ml-2 font-normal tracking-normal text-muted-foreground normal-case\"\n            >\n              {shown}\n            </span>\n          ) : null}\n        </p>\n        {action}\n      </div>\n\n      {loading ? (\n        <OptionSkeleton option={option} columns={columns} />\n      ) : option.values.length === 1 ? null : (\n        <RadioGroup\n          option={option}\n          labelledBy={`${id}-label`}\n          scope={id}\n          columns={columns}\n          onPreview={setPreview}\n        />\n      )}\n    </div>\n  )\n}\n\n/** One row up to six; past that, the column count that leaves the fewest gaps. */\nfunction columnsFor(count: number) {\n  if (count <= 6) return count\n  let best = 6\n  for (const c of [6, 5, 4]) {\n    if ((c - (count % c)) % c < (best - (count % best)) % best) best = c\n  }\n  return best\n}\n\nfunction RadioGroup({\n  option,\n  labelledBy,\n  scope,\n  columns,\n  onPreview,\n}: {\n  option: ResolvedVariantOption\n  labelledBy: string\n  scope: string\n  columns?: number\n  onPreview: (value: string | null) => void\n}) {\n  const { value, select } = useVariantSwatches()\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([])\n  const selectedIndex = option.values.findIndex((v) => v.value === value[option.name])\n  const grid = option.display === \"grid\"\n\n  const onKeyDown = (event: React.KeyboardEvent, index: number) => {\n    const last = option.values.length - 1\n    const next =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? index === last ? 0 : index + 1\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? index === 0 ? last : index - 1\n          : event.key === \"Home\"\n            ? 0\n            : event.key === \"End\"\n              ? last\n              : null\n    if (next === null) return\n    event.preventDefault()\n    refs.current[next]?.focus()\n  }\n\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-labelledby={labelledBy}\n      data-slot={grid ? \"variant-swatches-grid\" : \"variant-swatches-swatches\"}\n      onPointerLeave={() => onPreview(null)}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget)) onPreview(null)\n      }}\n      className={cn(\n        grid\n          ? // One ruled block: the cells draw the right and bottom rules.\n            \"grid border-t border-l\"\n          : // The hit area is wider than the chip; the chips line up with the label.\n            \"-ml-1.5 flex flex-wrap\"\n      )}\n      style={\n        grid\n          ? { gridTemplateColumns: `repeat(${columns ?? columnsFor(option.values.length)}, minmax(0, 1fr))` }\n          : undefined\n      }\n    >\n      {option.values.map((v, i) => (\n        <OptionButton\n          key={v.value}\n          buttonRef={(node) => {\n            refs.current[i] = node\n          }}\n          option={option}\n          item={v}\n          scope={scope}\n          checked={i === selectedIndex}\n          // Roving focus: Tab lands on the pick, or on the first option.\n          tabIndex={i === (selectedIndex === -1 ? 0 : selectedIndex) ? 0 : -1}\n          onKeyDown={(event) => onKeyDown(event, i)}\n          onClick={() => select(option.name, v.value)}\n          onPreview={onPreview}\n        />\n      ))}\n    </div>\n  )\n}\n\nfunction OptionButton({\n  buttonRef,\n  option,\n  item,\n  scope,\n  checked,\n  tabIndex,\n  onKeyDown,\n  onClick,\n  onPreview,\n}: {\n  // Not `ref`: on React 18 a function component never receives one.\n  buttonRef: React.RefCallback<HTMLButtonElement>\n  option: ResolvedVariantOption\n  item: ResolvedVariantValue\n  scope: string\n  checked: boolean\n  tabIndex: number\n  onKeyDown: (event: React.KeyboardEvent) => void\n  onClick: () => void\n  onPreview: (value: string | null) => void\n}) {\n  const { availability, messages } = useVariantSwatches()\n  const state = availability(option.name, item.value)\n  const out = state.kind === \"sold-out\" || state.kind === \"unavailable\"\n  const grid = option.display === \"grid\"\n\n  return (\n    <button\n      ref={buttonRef}\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={checked}\n      // Never `disabled`: a sold-out size stays in the tab order, is heard as\n      // sold out, and can be chosen — for a \"notify me\" — with the status\n      // line saying why it can't be bought.\n      tabIndex={tabIndex}\n      data-state={checked ? \"checked\" : \"unchecked\"}\n      data-availability={state.kind}\n      onKeyDown={onKeyDown}\n      onClick={onClick}\n      onPointerEnter={() => onPreview(item.value)}\n      onFocus={() => onPreview(item.value)}\n      className={cn(\n        \"relative isolate outline-none focus-visible:outline-2 focus-visible:outline-ring\",\n        grid\n          ? \"flex h-12 min-w-11 flex-col items-center justify-center border-r border-b px-2 text-sm tabular-nums transition-colors focus-visible:-outline-offset-2\"\n          : \"grid size-11 place-items-center focus-visible:-outline-offset-2\",\n        grid && !checked && \"hover:bg-secondary\",\n        grid && out && !checked && \"text-muted-foreground\",\n        grid && checked && \"text-background\"\n      )}\n    >\n      {checked ? <SelectedMark layoutId={`${scope}-selected`} grid={grid} /> : null}\n\n      {grid ? (\n        <>\n          <span className=\"relative z-10\">{item.label}</span>\n          {state.kind === \"low\" ? (\n            <span\n              aria-hidden\n              className={cn(\n                \"relative z-10 mt-0.5 text-[10.5px] leading-none tracking-wide\",\n                checked ? \"text-background/75\" : \"text-muted-foreground\"\n              )}\n            >\n              {messages.stockLeft(state.stock)}\n            </span>\n          ) : null}\n          {out ? <Strike className={checked ? \"text-background/70\" : \"text-muted-foreground/70\"} /> : null}\n        </>\n      ) : (\n        <span\n          aria-hidden\n          data-slot=\"variant-swatches-chip\"\n          className={cn(\n            \"relative size-8 border border-foreground/25 transition-opacity\",\n            out && \"opacity-60\"\n          )}\n          style={{ background: item.swatch }}\n        >\n          {out ? <Strike halo /> : null}\n        </span>\n      )}\n\n      <span className=\"sr-only\">\n        {grid ? \"\" : item.label}\n        {out\n          ? `, ${state.kind === \"sold-out\" ? messages.soldOutLabel : messages.unavailableLabel}`\n          : state.kind === \"low\"\n            ? `, ${messages.stockLeft(state.stock)}`\n            : \"\"}\n      </span>\n    </button>\n  )\n}\n\n/**\n * The pick's mark: ink behind a size, a ruled square around a swatch. One per\n * option, so Motion slides it from the old pick to the new; under reduced\n * motion it fades in where it lands.\n */\nfunction SelectedMark({ layoutId, grid }: { layoutId: string; grid: boolean }) {\n  const reduce = useReducedMotion()\n  return (\n    <motion.span\n      aria-hidden\n      data-slot=\"variant-swatches-mark\"\n      layoutId={reduce ? undefined : layoutId}\n      initial={reduce ? { opacity: 0 } : false}\n      animate={{ opacity: 1 }}\n      transition={reduce ? { duration: 0.15 } : MORPH}\n      className={cn(\n        \"absolute\",\n        grid ? \"inset-0 bg-foreground\" : \"inset-[3px] border border-foreground\"\n      )}\n    />\n  )\n}\n\n/**\n * The diagonal rule that says \"not this one\" without relying on colour. On\n * a swatch it has a halo in the page colour, so it reads on black as well\n * as on chalk.\n */\nfunction Strike({ className, halo = false }: { className?: string; halo?: boolean }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 100 100\"\n      preserveAspectRatio=\"none\"\n      className={cn(\"pointer-events-none absolute inset-0 z-10 size-full\", className)}\n    >\n      {halo ? (\n        <line\n          x1=\"0\" y1=\"100\" x2=\"100\" y2=\"0\"\n          vectorEffect=\"non-scaling-stroke\"\n          strokeWidth={3}\n          className=\"stroke-background\"\n        />\n      ) : null}\n      <line\n        x1=\"0\" y1=\"100\" x2=\"100\" y2=\"0\"\n        vectorEffect=\"non-scaling-stroke\"\n        strokeWidth={1}\n        className={halo ? \"stroke-foreground\" : \"stroke-current\"}\n      />\n    </svg>\n  )\n}\n\n/** Placeholders in the option's own shape, so nothing jumps when stock lands. */\nfunction OptionSkeleton({ option, columns }: { option: ResolvedVariantOption; columns?: number }) {\n  const count = Math.max(option.values.length, 1)\n  if (option.display === \"swatch\") {\n    return (\n      <div aria-hidden className=\"-ml-1.5 flex flex-wrap\">\n        {Array.from({ length: count }, (_, i) => (\n          <span key={i} className=\"grid size-11 place-items-center\">\n            <span className=\"size-8 animate-pulse bg-muted\" />\n          </span>\n        ))}\n      </div>\n    )\n  }\n  return (\n    <div\n      aria-hidden\n      className=\"grid border-t border-l\"\n      style={{ gridTemplateColumns: `repeat(${columns ?? columnsFor(count)}, minmax(0, 1fr))` }}\n    >\n      {Array.from({ length: count }, (_, i) => (\n        <span key={i} className=\"grid h-12 place-items-center border-r border-b\">\n          <span className=\"h-3 w-5 animate-pulse bg-muted\" />\n        </span>\n      ))}\n    </div>\n  )\n}\n\n/**\n * The line under the picker: \"Only 2 left.\", or why the picks can't be\n * bought — \"41 is sold out in Black.\" A polite live region, always in the page so every change is\n * read out; the line grows open and shut with Motion.\n */\nexport function VariantSwatchesStatus({\n  className,\n  ...props\n}: Omit<React.HTMLAttributes<HTMLDivElement>, \"children\">) {\n  const { status, loading } = useVariantSwatches()\n  const reduce = useReducedMotion()\n  const message = loading ? null : status\n\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      data-slot=\"variant-swatches-status\"\n      className={className}\n      {...props}\n    >\n      <AnimatePresence initial={false}>\n        {message ? (\n          <motion.div\n            key=\"status\"\n            initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            animate={reduce ? { opacity: 1 } : { height: \"auto\", opacity: 1 }}\n            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n            transition={reduce ? { duration: 0.15 } : MORPH}\n            className=\"overflow-hidden\"\n          >\n            <p className=\"flex items-baseline gap-2.5 pt-4 text-sm text-foreground\">\n              <span aria-hidden className=\"h-px w-4 shrink-0 translate-y-[-0.3em] bg-foreground\" />\n              {message}\n            </p>\n          </motion.div>\n        ) : null}\n      </AnimatePresence>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/variant-swatches.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "product",
    "pdp",
    "variants"
  ],
  "type": "registry:block"
}