ecomcn
All blocks

Product/block

Variant swatches

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().

See variant swatches in a working shop — the demo store is built from every ecomcn block.Open the demo store
$ npx shadcn@latest add @ecomcn/variant-swatches
Older shadcn CLI, or want the registry pinned?

Register the namespace yourself, once per project, then run the same add command. {name} is literal — the CLI fills it in.

$ npx shadcn@latest registry add @ecomcn=https://ecomcn.vercel.app/r/{name}.json
Preview — live
100%

Usage

From your product data
import {
  VariantSwatches,
  type Variant,
  type VariantOption,
} from "@/components/ecomcn/variant-swatches"

const options: VariantOption[] = [
  {
    name: "Colour",
    // swatch is any CSS background: a colour, or url(...) for a fabric.
    values: [
      { value: "Tan", swatch: "#a8764a" },
      { value: "Black", swatch: "#1c1b1a" },
    ],
  },
  { name: "Size", values: ["39", "40", "41", "42"] },
]

// One per SKU. stock: 0 is sold out; leave it out if you don't track stock.
// A combination with no variant at all reads as "isn't made in".
const variants: Variant[] = [
  { id: "tan-39", options: { Colour: "Tan", Size: "39" }, stock: 4 },
  { id: "tan-40", options: { Colour: "Tan", Size: "40" }, stock: 0 },
  // …
]

<VariantSwatches
  options={options}
  variants={variants}
  defaultValue={{ Colour: "Tan" }}
  onVariantChange={(variant) => setSku(variant?.id)}
  sizeGuide={<SizeGuideDialog columns={columns} rows={rows} />}
/>
Composed — your order, your parts
import {
  VariantSwatches,
  VariantSwatchesOption,
  VariantSwatchesStatus,
  useVariantSwatches,
} from "@/components/ecomcn/variant-swatches"

<VariantSwatches options={options} variants={variants} className="gap-7">
  <VariantSwatchesOption name="Size" action={<SizeGuideDialog … />} />
  <VariantSwatchesOption name="Colour" />
  <AddToBag />
  <VariantSwatchesStatus />
</VariantSwatches>

// Parts read the resolved variant: undefined until every option is picked.
function AddToBag() {
  const { variant, purchasable } = useVariantSwatches()
  return (
    <Button disabled={!purchasable} onClick={() => addToBag(variant!.id)}>
      Add to bag
    </Button>
  )
}

Props & context

Read straight out of the source at build time, so this cannot drift from the file you install.

VariantSwatchesContextValue
options: ResolvedVariantOption[]
  /** The selection, with any one-value option ("One size") filled in. */
  value: VariantSelection
  /** Picks a value — a sold-out one too: `purchasable` and the status say so. */
  select: (name: string, value: string) => void
  /** Stock for one option's value, given the shopper's other picks. */
  availability: (name: string, value: string) => VariantAvailability
  /** The variant every option points at, once each has a pick. */
  variant: Variant | undefined
  /** False until every option is picked, while stock loads, and while sold out. */
  purchasable: boolean
  /** What the status line says right now, or null. */
  status: string | null
  messages: VariantSwatchesMessages
  loading: boolean
VariantSwatchesProps
options: VariantOption[]
  /** Leave out to offer every combination, with no stock shown. */
  variants?: Variant[]
  value?: VariantSelection
  defaultValue?: VariantSelection
  onValueChange?: (value: VariantSelection) => void
  /** The variant the picks now point at — undefined until each option has one. */
  onVariantChange?: (variant: Variant | undefined) => void
  /** At or below this many, stock is shown: "Only 2 left." Defaults to 3. */
  lowStockThreshold?: number
  /** Rendered beside the first size grid's label — a `<SizeGuideDialog>`, say. */
  sizeGuide?: React.ReactNode
  messages?: Partial<VariantSwatchesMessages>
  /** Stock still on its way: placeholders in the shape of the options. */
  loading?: boolean
VariantSwatchesOptionProps
/** Which option: matches a `name` in `options`. */
  name: string
  /** Beside the label, on the right — a size guide link. */
  action?: React.ReactNode
  /** Grid columns. Defaults to one row up to six sizes, then even rows. */
  columns?: number

Source

components/ecomcn/variant-swatches.tsx
"use client"

import * as React from "react"
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"

/*
 * The variant picker on a product page: colour swatches, a size grid, and a
 * line that says what is left.
 *
 *   <VariantSwatches options={options} variants={variants} defaultValue={{ Colour: "Tan" }} />
 *
 *   <VariantSwatches options={options} variants={variants}>      or composed
 *     <VariantSwatchesOption name="Colour" />
 *     <VariantSwatchesOption name="Size" action={<SizeGuideDialog … />} />
 *     <VariantSwatchesStatus />
 *   </VariantSwatches>
 *
 * Stock is worked out per option from the variants you pass: with Tan
 * picked, a size Tan has none of reads as sold out, and a size with two
 * pairs left says so. A sold-out option is struck through with a diagonal
 * rule and read out as sold out, but it can still be chosen — a shopper who
 * wants that size can ask to be told when it's back — and the status line
 * says why it can't be bought. The selection mark slides between options
 * with Motion; under reduced motion it fades. Parts share the selection and
 * the variant it points at through context; `useVariantSwatches()` reads it.
 */

export interface VariantValue {
  value: string
  /** Shown text, when it differs from `value`. */
  label?: string
  /** A CSS background — `"#a8764a"`, or `"url(/linen.jpg) center / cover"`. */
  swatch?: string
}

export interface VariantOption {
  /** "Colour", "Size" — also the key in the selection and in each variant. */
  name: string
  values: (string | VariantValue)[]
  /** Defaults to "swatch" when any value has a swatch, "grid" otherwise. */
  display?: "swatch" | "grid"
}

export interface Variant {
  id: string
  /** One value per option: `{ Colour: "Tan", Size: "41" }`. */
  options: Record<string, string>
  /** Units on hand. 0 is sold out; leave it out when you don't track stock. */
  stock?: number
}

/** The shopper's picks, by option name. Options not yet chosen are absent. */
export type VariantSelection = Record<string, string>

export type VariantAvailability =
  | { kind: "available" }
  | { kind: "low"; stock: number }
  | { kind: "sold-out" }
  /** No variant has this combination at all. */
  | { kind: "unavailable" }

export interface VariantSwatchesMessages {
  /** Read out after a sold-out option's name. */
  soldOutLabel: string
  /** Read out after the name of a combination that isn't made. */
  unavailableLabel: string
  /** Under a size in the grid, when few are left: "2 left". */
  stockLeft: (stock: number) => string
  /** The status line, for the chosen variant. */
  lowStock: (stock: number) => string
  /** "41 is sold out in Tan." `others` are the other picks, as labels. */
  soldOut: (value: string, others: string[]) => string
  unavailable: (value: string, others: string[]) => string
}

const MESSAGES: VariantSwatchesMessages = {
  soldOutLabel: "sold out",
  unavailableLabel: "not available",
  stockLeft: (stock) => `${stock} left`,
  lowStock: (stock) => `Only ${stock} left.`,
  soldOut: (value, others) =>
    `${value} is sold out${others.length ? ` in ${others.join(", ")}` : ""}.`,
  unavailable: (value, others) =>
    `${value} isn't made${others.length ? ` in ${others.join(", ")}` : ""}.`,
}

/** The same settle curve as the other ecomcn morphs: no bounce, an editorial ease. */
const MORPH = { duration: 0.45, ease: [0.2, 0, 0, 1] } as const

const AVAILABLE: VariantAvailability = { kind: "available" }

/** A value with its label filled in, as the parts see it. */
export interface ResolvedVariantValue {
  value: string
  label: string
  swatch?: string
}

/** An option with its display settled, as the parts see it. */
export interface ResolvedVariantOption {
  name: string
  display: "swatch" | "grid"
  values: ResolvedVariantValue[]
}

function normalize(options: VariantOption[]): ResolvedVariantOption[] {
  return options.map((option) => {
    const values = option.values.map((v) =>
      typeof v === "string" ? { value: v, label: v } : { ...v, label: v.label ?? v.value }
    )
    return {
      name: option.name,
      display: option.display ?? (values.some((v) => v.swatch) ? "swatch" : "grid"),
      values,
    }
  })
}

function useControllableState<T>(
  value: T | undefined,
  defaultValue: T,
  onChange?: (next: T) => void
) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue)
  const controlled = value !== undefined
  const set = React.useCallback(
    (next: T) => {
      if (!controlled) setUncontrolled(next)
      onChange?.(next)
    },
    [controlled, onChange]
  )
  return [controlled ? value : uncontrolled, set] as const
}

/* ─── context ──────────────────────────────────────────────────────────── */

export interface VariantSwatchesContextValue {
  options: ResolvedVariantOption[]
  /** The selection, with any one-value option ("One size") filled in. */
  value: VariantSelection
  /** Picks a value — a sold-out one too: `purchasable` and the status say so. */
  select: (name: string, value: string) => void
  /** Stock for one option's value, given the shopper's other picks. */
  availability: (name: string, value: string) => VariantAvailability
  /** The variant every option points at, once each has a pick. */
  variant: Variant | undefined
  /** False until every option is picked, while stock loads, and while sold out. */
  purchasable: boolean
  /** What the status line says right now, or null. */
  status: string | null
  messages: VariantSwatchesMessages
  loading: boolean
}

const VariantSwatchesContext = React.createContext<VariantSwatchesContextValue | null>(null)

export function useVariantSwatches() {
  const context = React.useContext(VariantSwatchesContext)
  if (!context) {
    throw new Error("useVariantSwatches must be used within <VariantSwatches>.")
  }
  return context
}

/* ─── root ─────────────────────────────────────────────────────────────── */

export interface VariantSwatchesProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "defaultValue" | "onChange"> {
  options: VariantOption[]
  /** Leave out to offer every combination, with no stock shown. */
  variants?: Variant[]
  value?: VariantSelection
  defaultValue?: VariantSelection
  onValueChange?: (value: VariantSelection) => void
  /** The variant the picks now point at — undefined until each option has one. */
  onVariantChange?: (variant: Variant | undefined) => void
  /** At or below this many, stock is shown: "Only 2 left." Defaults to 3. */
  lowStockThreshold?: number
  /** Rendered beside the first size grid's label — a `<SizeGuideDialog>`, say. */
  sizeGuide?: React.ReactNode
  messages?: Partial<VariantSwatchesMessages>
  /** Stock still on its way: placeholders in the shape of the options. */
  loading?: boolean
}

export function VariantSwatches({
  options: optionsProp,
  variants,
  value: valueProp,
  defaultValue = {},
  onValueChange,
  onVariantChange,
  lowStockThreshold = 3,
  sizeGuide,
  messages: messagesProp,
  loading = false,
  className,
  children,
  ...props
}: VariantSwatchesProps) {
  const [picked, setPicked] = useControllableState(valueProp, defaultValue, onValueChange)
  const options = React.useMemo(() => normalize(optionsProp), [optionsProp])
  const messages = React.useMemo(() => ({ ...MESSAGES, ...messagesProp }), [messagesProp])

  const value = React.useMemo(() => {
    const filled: VariantSelection = {}
    for (const option of options) {
      if (option.values.length === 1) filled[option.name] = option.values[0].value
    }
    return { ...filled, ...picked }
  }, [options, picked])

  const availability = React.useCallback(
    (name: string, candidate: string): VariantAvailability => {
      if (!variants) return AVAILABLE
      let exists = false
      let untracked = false
      let stock = 0
      for (const variant of variants) {
        if (variant.options[name] !== candidate) continue
        const fits = options.every(
          (o) =>
            o.name === name ||
            value[o.name] === undefined ||
            variant.options[o.name] === value[o.name]
        )
        if (!fits) continue
        exists = true
        if (variant.stock === undefined) untracked = true
        else stock += Math.max(0, variant.stock)
      }
      if (!exists) return { kind: "unavailable" }
      if (untracked) return AVAILABLE
      if (stock === 0) return { kind: "sold-out" }
      if (stock <= lowStockThreshold) return { kind: "low", stock }
      return AVAILABLE
    },
    [variants, options, value, lowStockThreshold]
  )

  const find = React.useCallback(
    (selection: VariantSelection) =>
      options.every((o) => selection[o.name] !== undefined)
        ? variants?.find((v) => options.every((o) => v.options[o.name] === selection[o.name]))
        : undefined,
    [options, variants]
  )

  const variant = find(value)
  const complete = options.every((o) => value[o.name] !== undefined)
  const purchasable =
    !loading && complete && (!variants || (variant !== undefined && variant.stock !== 0))

  const select = React.useCallback(
    (name: string, next: string) => {
      if (value[name] === next) return
      setPicked({ ...picked, [name]: next })
      onVariantChange?.(find({ ...value, [name]: next }))
    },
    [value, picked, setPicked, onVariantChange, find]
  )

  const status = React.useMemo(() => {
    const labelOf = (name: string, v: string) =>
      options.find((o) => o.name === name)?.values.find((x) => x.value === v)?.label ?? v
    const othersThan = (name: string) =>
      options
        .filter((o) => o.name !== name && value[o.name] !== undefined && o.values.length > 1)
        .map((o) => labelOf(o.name, value[o.name]))

    if (!variants) return null
    if (!complete) {
      // Part-way through: a pick that can't be had whatever comes next.
      for (const option of options) {
        const picked = value[option.name]
        if (picked === undefined || option.values.length < 2) continue
        const state = availability(option.name, picked)
        const label = labelOf(option.name, picked)
        if (state.kind === "sold-out") return messages.soldOut(label, othersThan(option.name))
        if (state.kind === "unavailable") return messages.unavailable(label, othersThan(option.name))
      }
      return null
    }
    // Explained by the last option — the size, usually — "in" the others.
    const last = [...options].reverse().find((o) => o.values.length > 1) ?? options[options.length - 1]
    const label = labelOf(last.name, value[last.name])
    if (!variant) return messages.unavailable(label, othersThan(last.name))
    if (variant.stock === 0) return messages.soldOut(label, othersThan(last.name))
    if (variant.stock !== undefined && variant.stock <= lowStockThreshold) {
      return messages.lowStock(variant.stock)
    }
    return null
  }, [availability, complete, variants, variant, options, value, messages, lowStockThreshold])

  const context = React.useMemo<VariantSwatchesContextValue>(
    () => ({
      options,
      value,
      select,
      availability,
      variant,
      purchasable,
      status,
      messages,
      loading,
    }),
    [options, value, select, availability, variant, purchasable, status, messages, loading]
  )

  // A product with nothing to choose: there is no picker to show.
  if (!children && !options.some((o) => o.values.length > 0)) return null
  const firstGrid = options.find((o) => o.display === "grid" && o.values.length > 1)

  return (
    <VariantSwatchesContext.Provider value={context}>
      <MotionConfig reducedMotion="user">
        <div
          data-slot="variant-swatches"
          aria-busy={loading || undefined}
          className={cn("flex flex-col", className)}
          {...props}
        >
          {children ?? (
            <>
              <div className="flex flex-col gap-7">
                {options.map((option) => (
                  <VariantSwatchesOption
                    key={option.name}
                    name={option.name}
                    action={option === firstGrid ? sizeGuide : undefined}
                  />
                ))}
              </div>
              <VariantSwatchesStatus />
            </>
          )}
        </div>
      </MotionConfig>
    </VariantSwatchesContext.Provider>
  )
}

/* ─── parts ────────────────────────────────────────────────────────────── */

export interface VariantSwatchesOptionProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** Which option: matches a `name` in `options`. */
  name: string
  /** Beside the label, on the right — a size guide link. */
  action?: React.ReactNode
  /** Grid columns. Defaults to one row up to six sizes, then even rows. */
  columns?: number
}

/**
 * One option: its label, the value picked (or the one under the pointer or
 * focus), and the swatches or grid. A radio group — Tab enters on the pick,
 * the arrow keys move, Space or Enter chooses. Moving never chooses, so
 * arrowing past a colour doesn't swap the product photo on the way.
 */
export function VariantSwatchesOption({
  name,
  action,
  columns,
  className,
  ...props
}: VariantSwatchesOptionProps) {
  const { options, value, loading } = useVariantSwatches()
  const option = options.find((o) => o.name === name)
  const id = React.useId()
  const [preview, setPreview] = React.useState<string | null>(null)

  if (!option || option.values.length === 0) return null

  const selected = value[name]
  const shown = option.values.find((v) => v.value === (preview ?? selected))?.label

  return (
    <div
      data-slot="variant-swatches-option"
      data-option={name}
      data-display={option.display}
      className={cn("min-w-0", className)}
      {...props}
    >
      <div className="mb-3 flex min-h-5 items-baseline justify-between gap-4">
        <p className="text-xs font-medium tracking-[0.14em] uppercase">
          <span id={`${id}-label`}>{option.name}</span>
          {shown && !loading ? (
            <span
              // A sighted echo of the checked radio — except for a one-value
              // option ("One size"), which has no radio and is read from here.
              aria-hidden={option.values.length > 1 || undefined}
              data-slot="variant-swatches-value"
              className="ml-2 font-normal tracking-normal text-muted-foreground normal-case"
            >
              {shown}
            </span>
          ) : null}
        </p>
        {action}
      </div>

      {loading ? (
        <OptionSkeleton option={option} columns={columns} />
      ) : option.values.length === 1 ? null : (
        <RadioGroup
          option={option}
          labelledBy={`${id}-label`}
          scope={id}
          columns={columns}
          onPreview={setPreview}
        />
      )}
    </div>
  )
}

/** One row up to six; past that, the column count that leaves the fewest gaps. */
function columnsFor(count: number) {
  if (count <= 6) return count
  let best = 6
  for (const c of [6, 5, 4]) {
    if ((c - (count % c)) % c < (best - (count % best)) % best) best = c
  }
  return best
}

function RadioGroup({
  option,
  labelledBy,
  scope,
  columns,
  onPreview,
}: {
  option: ResolvedVariantOption
  labelledBy: string
  scope: string
  columns?: number
  onPreview: (value: string | null) => void
}) {
  const { value, select } = useVariantSwatches()
  const refs = React.useRef<(HTMLButtonElement | null)[]>([])
  const selectedIndex = option.values.findIndex((v) => v.value === value[option.name])
  const grid = option.display === "grid"

  const onKeyDown = (event: React.KeyboardEvent, index: number) => {
    const last = option.values.length - 1
    const next =
      event.key === "ArrowRight" || event.key === "ArrowDown"
        ? index === last ? 0 : index + 1
        : event.key === "ArrowLeft" || event.key === "ArrowUp"
          ? index === 0 ? last : index - 1
          : event.key === "Home"
            ? 0
            : event.key === "End"
              ? last
              : null
    if (next === null) return
    event.preventDefault()
    refs.current[next]?.focus()
  }

  return (
    <div
      role="radiogroup"
      aria-labelledby={labelledBy}
      data-slot={grid ? "variant-swatches-grid" : "variant-swatches-swatches"}
      onPointerLeave={() => onPreview(null)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) onPreview(null)
      }}
      className={cn(
        grid
          ? // One ruled block: the cells draw the right and bottom rules.
            "grid border-t border-l"
          : // The hit area is wider than the chip; the chips line up with the label.
            "-ml-1.5 flex flex-wrap"
      )}
      style={
        grid
          ? { gridTemplateColumns: `repeat(${columns ?? columnsFor(option.values.length)}, minmax(0, 1fr))` }
          : undefined
      }
    >
      {option.values.map((v, i) => (
        <OptionButton
          key={v.value}
          buttonRef={(node) => {
            refs.current[i] = node
          }}
          option={option}
          item={v}
          scope={scope}
          checked={i === selectedIndex}
          // Roving focus: Tab lands on the pick, or on the first option.
          tabIndex={i === (selectedIndex === -1 ? 0 : selectedIndex) ? 0 : -1}
          onKeyDown={(event) => onKeyDown(event, i)}
          onClick={() => select(option.name, v.value)}
          onPreview={onPreview}
        />
      ))}
    </div>
  )
}

function OptionButton({
  buttonRef,
  option,
  item,
  scope,
  checked,
  tabIndex,
  onKeyDown,
  onClick,
  onPreview,
}: {
  // Not `ref`: on React 18 a function component never receives one.
  buttonRef: React.RefCallback<HTMLButtonElement>
  option: ResolvedVariantOption
  item: ResolvedVariantValue
  scope: string
  checked: boolean
  tabIndex: number
  onKeyDown: (event: React.KeyboardEvent) => void
  onClick: () => void
  onPreview: (value: string | null) => void
}) {
  const { availability, messages } = useVariantSwatches()
  const state = availability(option.name, item.value)
  const out = state.kind === "sold-out" || state.kind === "unavailable"
  const grid = option.display === "grid"

  return (
    <button
      ref={buttonRef}
      type="button"
      role="radio"
      aria-checked={checked}
      // Never `disabled`: a sold-out size stays in the tab order, is heard as
      // sold out, and can be chosen — for a "notify me" — with the status
      // line saying why it can't be bought.
      tabIndex={tabIndex}
      data-state={checked ? "checked" : "unchecked"}
      data-availability={state.kind}
      onKeyDown={onKeyDown}
      onClick={onClick}
      onPointerEnter={() => onPreview(item.value)}
      onFocus={() => onPreview(item.value)}
      className={cn(
        "relative isolate outline-none focus-visible:outline-2 focus-visible:outline-ring",
        grid
          ? "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"
          : "grid size-11 place-items-center focus-visible:-outline-offset-2",
        grid && !checked && "hover:bg-secondary",
        grid && out && !checked && "text-muted-foreground",
        grid && checked && "text-background"
      )}
    >
      {checked ? <SelectedMark layoutId={`${scope}-selected`} grid={grid} /> : null}

      {grid ? (
        <>
          <span className="relative z-10">{item.label}</span>
          {state.kind === "low" ? (
            <span
              aria-hidden
              className={cn(
                "relative z-10 mt-0.5 text-[10.5px] leading-none tracking-wide",
                checked ? "text-background/75" : "text-muted-foreground"
              )}
            >
              {messages.stockLeft(state.stock)}
            </span>
          ) : null}
          {out ? <Strike className={checked ? "text-background/70" : "text-muted-foreground/70"} /> : null}
        </>
      ) : (
        <span
          aria-hidden
          data-slot="variant-swatches-chip"
          className={cn(
            "relative size-8 border border-foreground/25 transition-opacity",
            out && "opacity-60"
          )}
          style={{ background: item.swatch }}
        >
          {out ? <Strike halo /> : null}
        </span>
      )}

      <span className="sr-only">
        {grid ? "" : item.label}
        {out
          ? `, ${state.kind === "sold-out" ? messages.soldOutLabel : messages.unavailableLabel}`
          : state.kind === "low"
            ? `, ${messages.stockLeft(state.stock)}`
            : ""}
      </span>
    </button>
  )
}

/**
 * The pick's mark: ink behind a size, a ruled square around a swatch. One per
 * option, so Motion slides it from the old pick to the new; under reduced
 * motion it fades in where it lands.
 */
function SelectedMark({ layoutId, grid }: { layoutId: string; grid: boolean }) {
  const reduce = useReducedMotion()
  return (
    <motion.span
      aria-hidden
      data-slot="variant-swatches-mark"
      layoutId={reduce ? undefined : layoutId}
      initial={reduce ? { opacity: 0 } : false}
      animate={{ opacity: 1 }}
      transition={reduce ? { duration: 0.15 } : MORPH}
      className={cn(
        "absolute",
        grid ? "inset-0 bg-foreground" : "inset-[3px] border border-foreground"
      )}
    />
  )
}

/**
 * The diagonal rule that says "not this one" without relying on colour. On
 * a swatch it has a halo in the page colour, so it reads on black as well
 * as on chalk.
 */
function Strike({ className, halo = false }: { className?: string; halo?: boolean }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 100 100"
      preserveAspectRatio="none"
      className={cn("pointer-events-none absolute inset-0 z-10 size-full", className)}
    >
      {halo ? (
        <line
          x1="0" y1="100" x2="100" y2="0"
          vectorEffect="non-scaling-stroke"
          strokeWidth={3}
          className="stroke-background"
        />
      ) : null}
      <line
        x1="0" y1="100" x2="100" y2="0"
        vectorEffect="non-scaling-stroke"
        strokeWidth={1}
        className={halo ? "stroke-foreground" : "stroke-current"}
      />
    </svg>
  )
}

/** Placeholders in the option's own shape, so nothing jumps when stock lands. */
function OptionSkeleton({ option, columns }: { option: ResolvedVariantOption; columns?: number }) {
  const count = Math.max(option.values.length, 1)
  if (option.display === "swatch") {
    return (
      <div aria-hidden className="-ml-1.5 flex flex-wrap">
        {Array.from({ length: count }, (_, i) => (
          <span key={i} className="grid size-11 place-items-center">
            <span className="size-8 animate-pulse bg-muted" />
          </span>
        ))}
      </div>
    )
  }
  return (
    <div
      aria-hidden
      className="grid border-t border-l"
      style={{ gridTemplateColumns: `repeat(${columns ?? columnsFor(count)}, minmax(0, 1fr))` }}
    >
      {Array.from({ length: count }, (_, i) => (
        <span key={i} className="grid h-12 place-items-center border-r border-b">
          <span className="h-3 w-5 animate-pulse bg-muted" />
        </span>
      ))}
    </div>
  )
}

/**
 * The line under the picker: "Only 2 left.", or why the picks can't be
 * bought — "41 is sold out in Black." A polite live region, always in the page so every change is
 * read out; the line grows open and shut with Motion.
 */
export function VariantSwatchesStatus({
  className,
  ...props
}: Omit<React.HTMLAttributes<HTMLDivElement>, "children">) {
  const { status, loading } = useVariantSwatches()
  const reduce = useReducedMotion()
  const message = loading ? null : status

  return (
    <div
      role="status"
      aria-live="polite"
      data-slot="variant-swatches-status"
      className={className}
      {...props}
    >
      <AnimatePresence initial={false}>
        {message ? (
          <motion.div
            key="status"
            initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
            animate={reduce ? { opacity: 1 } : { height: "auto", opacity: 1 }}
            exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}
            transition={reduce ? { duration: 0.15 } : MORPH}
            className="overflow-hidden"
          >
            <p className="flex items-baseline gap-2.5 pt-4 text-sm text-foreground">
              <span aria-hidden className="h-px w-4 shrink-0 translate-y-[-0.3em] bg-foreground" />
              {message}
            </p>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  )
}