HextaUI

Variant Picker

Color swatches and size buttons for a product page. Handles sold-out sizes, combinations that don’t exist, a missing size on add to bag and back-in-stock emails.

Open in new tab

Variant Picker shows a product’s options the way shoppers expect: colors as swatches, sizes as buttons, never a dropdown that hides what’s in stock. It works out every option from the variants you pass, so each value knows whether it’s available, sold out or not made in the current combination.

Availability follows the other choices. A color stays available while any of its sizes is in stock, and a size is crossed out only for the colors it’s sold out in. Sold-out values stay selectable, so a shopper can pick one and ask to be emailed when it’s back. Sizes a color isn’t made in are disabled and say so. Picking a color that doesn’t come in the chosen size clears the size and tells screen readers why.

One ring slides between the chosen swatch or size. Hover a swatch and the label previews its name while onPreviewChange lets the product image follow. Sizes are never preselected: validate() flags a missing option with “Select a size”, focuses it and gives it a small shake, which reduced motion turns off.

VariantAvailability reads the chosen variant as In stock, Only 3 left, Pre-order with the ship date, or Backordered. NotifyMe collects an email for a sold-out variant. The helpers, findVariant, getOptionStates, getInitialSelection, getPriceRange and the search param pair, work on their own, on the server too.

  1. HextaUI Pro を入手する

    一度の支払いで、今後追加されるものも含めてすべてのブロックが使えます。料金を見る。

  2. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  4. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/variant-picker

A buy box

Keep the selection in state and find the variant with findVariant. When the shopper adds to bag before choosing a size, validate() flags the size, moves focus there and returns false. A sold-out variant swaps the button for NotifyMe.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import { Price } from "../price/price"
import { NotifyMe } from "@/components/blocks/variant-picker/notify-me"
import { findVariant, getInitialSelection, getPriceRange, type Product } from "@/components/blocks/variant-picker/product"
import { VariantAvailability } from "@/components/blocks/variant-picker/variant-availability"
import { VariantPicker, type VariantPickerActions } from "@/components/blocks/variant-picker/variant-picker"

export function BuyBox({
  product,
  onAddToCart,
  onNotify,
}: {
  product: Product
  onAddToCart: (variantId: string) => Promise<void>
  onNotify: (variantId: string, email: string) => Promise<void>
}) {
  const [selection, setSelection] = React.useState(() => getInitialSelection(product))
  const picker = React.useRef<VariantPickerActions>(null)
  const variant = findVariant(product, selection)

  return (
    <div className="flex flex-col gap-6">
      <Price
        size="lg"
        value={variant?.price ?? getPriceRange(product.variants)}
        compareAt={variant?.compareAtPrice}
      />
      <VariantPicker
        product={product}
        value={selection}
        onValueChange={setSelection}
        actionsRef={picker}
      />
      <VariantAvailability variant={variant} />
      {variant?.availability === "sold_out" ? (
        <NotifyMe
          key={variant.id}
          variantTitle={variant.title}
          onSubmit={(email) => onNotify(variant.id, email)}
        />
      ) : (
        <Button
          size="lg"
          feedback
          successLabel="Added to bag"
          onClick={() => {
            if (!picker.current?.validate() || !variant) return
            return onAddToCart(variant.id)
          }}
        >
          {variant ? "Add to bag" : "Select a size"}
        </Button>
      )}
    </div>
  )
}

Keep the selection in the URL

selectionFromSearchParams and selectionToSearchParams read and write ?color=Oat&size=King, so a shared link opens the same variant and Back restores it. Values that aren’t in the product are dropped.

"use client"

import { usePathname, useRouter, useSearchParams } from "next/navigation"

import {
  getInitialSelection,
  selectionFromSearchParams,
  selectionToSearchParams,
  type Product,
} from "@/components/blocks/variant-picker/product"
import { VariantPicker } from "@/components/blocks/variant-picker/variant-picker"

export function UrlVariantPicker({ product }: { product: Product }) {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const selection = getInitialSelection(
    product,
    selectionFromSearchParams(product, searchParams)
  )

  return (
    <VariantPicker
      product={product}
      value={selection}
      onValueChange={(next) => {
        const params = selectionToSearchParams(
          product,
          next,
          new URLSearchParams(searchParams)
        )
        router.replace(`${pathname}?${params}`, { scroll: false })
      }}
    />
  )
}

From the Shopify Storefront API

fromShopify maps a Storefront API product to Product: swatch colors and images, prices and stock come across as they are, and availableForSale, currentlyNotInStock and quantityAvailable become the availability state.

import { fromShopify, type ShopifyProduct } from "@/components/blocks/variant-picker/shopify"
import { VariantPicker } from "@/components/blocks/variant-picker/variant-picker"

export function ShopifyVariantPicker({ product }: { product: ShopifyProduct }) {
  return <VariantPicker product={fromShopify(product)} />
}

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
VariantPickerOne labelled radio group per option, as swatches or buttons.
VariantAvailabilityThe stock line for the chosen variant.
NotifyMeAn inline back-in-stock email form.
findVariant, getOptionStates, getInitialSelectionFind the variant for a selection, the state of every value, and a sensible starting selection.
selectionFromSearchParams, selectionToSearchParamsRead and write the selection as URL search params.
fromShopifyMaps a Shopify Storefront API product to Product.

VariantPicker

Also takes div props.

プロパティ型デフォルト
productThe options in display order and every variant. Options earlier in the list take priority when a combination doesn’t exist.
{ options: ProductOption[]; variants: ProductVariant[] }–
valueThe selection, such as { Color: "Oat", Size: "King" }.
SelectedOptions–
defaultValueThe starting selection when uncontrolled.
SelectedOptions–
onValueChangeCalled with the new selection and its variant, or undefined until every option is chosen.
(selection, variant) => void–
onPreviewChangeCalled when a mouse hovers a value and leaves it, for previewing images.
(preview: { option: string; value: string } | null) => void–
actionsContent at the end of an option’s label row, keyed by option name, such as a size guide link.
Record<string, ReactNode>–
actionsRefvalidate() flags and focuses the first missing option and returns whether every option is chosen.
Ref<{ validate(): boolean }>–
localeLocale for the price in announcements.
Intl.LocalesArgument"en-US"
labelsWords for errors, sold out, announcements and stock.
Partial<VariantPickerLabels>–
プロパティ型デフォルト
nameThe key used in selectedOptions, such as Size.
string–
valuesValues in order. group puts values under small headings, and description shows under the option once chosen.
{ name; label?; swatch?: { color?; image? }; group?; description? }[]–
displayDefaults to swatch when any value has a swatch.
"swatch" | "chip"–
preselectLet getInitialSelection pick the first available value. Defaults to true for swatches only.
boolean–
プロパティ型デフォルト
idYour variant id.
string–
selectedOptionsThis variant’s value for each option.
Record<string, string>–
priceThe price.
Money–
compareAtPriceThe original price, for sales.
Money | null–
availabilityOnly sold_out stops a purchase.
"in_stock" | "low_stock" | "sold_out" | "preorder" | "backorder"–
quantityAvailableShown as “Only 3 left” when low.
number–
availableOnAn ISO date, shown for pre-orders and backorders.
string–
imageIdThe product image for this variant.
string–
プロパティ型デフォルト
onSubmitSave the request. Throw to show the message under the field.
(email: string) => void | Promise<void>–
variantTitleShown next to the title, such as Oat / Twin.
string–
defaultEmailPrefill for signed-in shoppers.
string–
キーアクション
TabMoves into an option at its chosen value, or its first value, then on to the next option.
ArrowLeftArrowRightArrowUpArrowDownMoves between values and selects them, skipping values that aren’t made in the current combination.
SpaceSelects the focused value.
  • Each option is a radio group named by its visible label. Values are named by their label, never a color code, with “Sold out” or “Not made in Stone” added when that applies.
  • Sold-out values stay focusable and selectable, so shoppers using a keyboard or screen reader can ask to be notified too.
  • A polite live region announces each choice with its price and stock, such as “King selected. $280.00. Only 3 left”, and says when a choice clears another one.
  • validate() sets aria-invalid and links the error text to the group, then moves focus into it. Status is never shown by color alone: sold out has a line and words, and stock has words next to its dot.

使用技術

Variant Picker を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

9 個のファイルを components/blocks/variant-picker に追加しました。