← ブログ

React で作る商品バリアントピッカーの UX: 売り切れのサイズ、存在しない組み合わせ、再入荷通知

色とサイズは、在庫が組み合わせごとにあると気づくまでは簡単に見えます。売り切れのサイズ、存在しない組み合わせ、写真、価格、URL に対応した React のバリアントピッカー。

Preet Suthar · · 5分で読めます

  • React
  • E-commerce
  • UX
  • Accessibility

3 色 5 サイズのシャツは、倉庫から見れば 15 個の商品です。在庫は「Oat」や「Large」ではなく、それぞれの組み合わせにあります。ほとんどのバリアントピッカーはそれを無視しています。色とサイズを独立した 2 つのリストとして表示し、Oat に XL がないことを買い物客が知るのは「カートに追加」が失敗したときです。

ここでは、在庫について正直なバリアントピッカーを React で作る方法を紹介します。売り切れのサイズ、存在しない組み合わせ、再入荷メール、そして選択に従う写真と価格です。

バリアントをモデル化し、オプションを導き出す

バリアントから始めます。それがコマース API の返すものであり、注文が指し示すものだからです。

type Variant = {
  id: string
  options: Record<string, string>
  available: boolean
  quantity?: number
  price: Money
  imageId?: string
}

type Product = {
  options: { name: string; values: string[] }[]
  variants: Variant[]
}

画面上のすべての値は、バリアントと現在の選択の残りから導かれる 3 つの状態のいずれかを持ちます。

  • 在庫あり: この値と他の選択中のオプションの組み合わせで、在庫のあるバリアントがある。
  • 売り切れ: 組み合わせは存在するが、今は在庫がない。
  • 取り扱いなし: その組み合わせはそもそも作られていない。
type ValueState = "available" | "sold-out" | "unavailable"

function getValueState(
  product: Product,
  selection: Record<string, string>,
  name: string,
  value: string
): ValueState {
  const wanted = { ...selection, [name]: value }
  const matches = product.variants.filter((variant) =>
    Object.entries(wanted).every(
      ([key, chosen]) => variant.options[key] === chosen
    )
  )
  if (matches.length === 0) return "unavailable"
  return matches.some((variant) => variant.available) ? "available" : "sold-out"
}

これで、特別なケースなしに買い物客が期待する挙動が得られます。色は、そのサイズのどれかに在庫がある限り在庫ありのままです。サイズに線が引かれるのは、売り切れている色についてだけです。別の色を選べばサイズが更新されます。

純粋関数なのでサーバーでも動きます。すべてのサイズを在庫ありとして一瞬表示してから一部に線を引くのではなく、最初の描画から正しい状態でレンダリングします。

スウォッチとボタン、ドロップダウンは使わない

<select> は、人が確認しに来たたった一つのこと、つまり自分のサイズに在庫があるかどうかを隠してしまいます。色はスウォッチ、サイズはボタンで表示すれば、全体像が一度に見えます。

意味的には、各オプションはラジオグループです。本物のラジオ入力か、ロービングフォーカス付きの role="radiogroup" を使い、矢印キーで値の間を移動でき、グループに名前があるようにします。状態は支援技術にも公開してください。取り消し線はスクリーンリーダーには見えず、他の人にとっても見落としやすいからです。

<label className="swatch" data-state={state}>
  <input
    type="radio"
    name="size"
    value={size}
    checked={selection.size === size}
    onChange={() => select("size", size)}
    disabled={state === "unavailable"}
    aria-describedby={state === "sold-out" ? "size-sold-out" : undefined}
  />
  {size}
</label>

スウォッチの上には、選択中の値をテキストで表示します(「カラー: Oat」)。ホバー時はそこにホバー中の値の名前をプレビューして、32px の円から色を推測しなくて済むようにします。

売り切れでも選択できるべき

よくある間違いは、売り切れのサイズを無効にすることです。それはどのサイズが存在するかという最も役立つ情報を隠し、再入荷メールへの導線も消してしまいます。

売り切れの値は選択できるままにします。選んだバリアントが売り切れなら、「カートに追加」を「再入荷したら通知する」フォームに差し替えます。保存するのは商品 ID ではなくバリアント ID なので、メールは「このシャツ」ではなく Oat の Large についての通知になります。

無効にすべきなのは存在しない組み合わせだけで、その理由をアクセシブルネームで伝えるべきです: 「XL、Oat では取り扱いなし」。

サイズを事前選択しない

最初のサイズを事前選択すると、ストアは S サイズを大量に出荷することになります。色はすでに写真に写っているので事前選択し、サイズは空のままにします。

サイズを選ばずに「カートに追加」が押されたら、黙って失敗したりアラートを開いたりしないでください。サイズグループに「サイズを選択してください」と示し、そこにフォーカスを移し、短く揺らします。この揺れは reduced motion でオフになります。

function addToBag() {
  const missing = product.options.find((option) => !selection[option.name])
  if (missing) {
    setError(missing.name, `Select a ${missing.name.toLowerCase()}`)
    groupRefs.current[missing.name]?.focus()
    return
  }
  add(findVariant(product, selection))
}

存在しない組み合わせへの切り替え

買い物客が Large を選んだ状態で、Small と Medium しかない色を選んだとします。選択肢は 2 つ。その色をブロックするか、受け入れてサイズをクリアするかです。色のほうがたいてい大きな決定なので、サイズをクリアするほうが良いでしょう。ただし伝えてください。「Rust には Large がありません。サイズを選択してください。」という polite なライブリージョンがあれば、スクリーンリーダーのユーザーが間違ったものを追加するのを防げ、他の人には見えているサイズグループが空の選択を示します。

写真と価格は選択に従う

色が変わったら、ギャラリーはその色の写真に移るべきです。間の画像をすべてスライドして通り過ぎるのではなくその場でクロスフェードし、新しい画像のデコードを待って、フレームが一瞬空になることがないようにします。その後も買い物客が他の写真を見続けられるようにしてください。バリアントが決めるのはギャラリーの開始位置であって、固定位置ではありません。

価格もバリアントごとに異なることがあります。すべて選ばれる前は「$28 から」のように範囲を表示します。バリアントが選ばれたら、その価格、セール中なら取り消し線付きの比較価格、そして在庫を言葉で表示します: 「残り 3 点」「予約受付中、11 月 12 日発送」「取り寄せ中」。価格には等幅数字(tabular figures)を使い、桁が変わっても行がずれないようにします。

選択を URL に入れる

?color=Oat&size=L があればバリアントを共有でき、リロードしても残り、広告やメールから買い物客が見たのと同じ商品にリンクできます。読み戻すときは慎重に、商品に存在しないものは捨ててください。

function selectionFromSearchParams(product: Product, params: URLSearchParams) {
  const selection: Record<string, string> = {}
  for (const { name, values } of product.options) {
    const value = params.get(name.toLowerCase())
    if (value && values.includes(value)) selection[name] = value
  }
  return selection
}

Next.js では、ページで searchParams を読み取ってサーバーが正しいバリアントをレンダリングするようにし、選択が変わったらクライアントから URL を更新します。変更ごとに履歴エントリーを追加するか現在のものを置き換えるかを決め、一貫させてください。

チェックリスト

  • オプションの状態をバリアントから導き出す: 在庫あり、売り切れ、取り扱いなし。
  • ラジオグループ内のスウォッチとボタン、状態はアクセシブルネームで。
  • 売り切れの値は選択できるままにし、バリアントごとの再入荷メールを提案する。
  • サイズは決して事前選択せず、未選択なら示してフォーカスする。
  • 色の変更でサイズが無効になったら、サイズをクリアして理由を伝える。
  • ギャラリーと価格はバリアントに従うが、ギャラリーを固定はしない。
  • 選択は URL に保持する。

これを実装した HextaUI のブロック

私はこれらのルールを HextaUI Pro の Variant Picker ブロックに組み込みました。渡されたバリアントからすべてのオプションを導き出し、売り切れの値は NotifyMe フォーム付きで選択可能なままにし、サイズは決して事前選択せず、未選択のときは「サイズを選択してください」と示します。ヘルパーの findVariant、getOptionStates、getInitialSelection、getPriceRange と検索パラメーターのペアは単体でもサーバーでも動き、fromShopify は Storefront API の商品を変換します。

Product Gallery と組み合わせられます。activeId を渡すとバリアントの写真にクロスフェードします。Price はどんな通貨でもフォーマットし、変わった桁だけを回転させます。3 つともライブプレビューがあり、購入前に試せます。

次にカートを作るなら、そこでも同じ配慮が必要です: React でのショッピングカート UX。

よくある質問

売り切れのサイズは無効にすべきですか? いいえ。選択できるままにして、再入荷のメールを提案します。無効にするのは、そもそも作られていない組み合わせだけです。

サイズを事前選択すべきですか? いいえ。色は事前選択してもかまいませんが、サイズはカートに追加するときに検証します。

バリアントにはスウォッチとドロップダウンのどちらを使うべきですか? スウォッチとボタンです。ドロップダウンは在庫を隠してしまいますが、それこそ人が探している情報です。

バリアントを URL に保持するには? オプションごとに検索パラメーターを 1 つ使い、最初のレンダリングのためにサーバーで読み取り、商品に存在しない値は無視します。