テクニック

Shopify 商品オプションのバリエーション表示改善|色見本と在庫切れの実装

この記事の対象: Shopifyテーマを直接編集する制作者・フロントエンドエンジニア
読了時間: 約12分

Shopifyの商品ページで、サイズや色の選択がセレクトボックスのまま納品されているストアは今も見かけます。Dawnをはじめとする多くのテーマは初期状態でセレクトボックスかラジオボタンを吐き出すだけで、色を色として見せる仕組みは持っていません。この記事では、Online Store 2.0 のテーマを対象に、Shopify の商品オプションのバリエーション表示をカラースウォッチとボタンに置き換える手順を、Liquid・CSS・JavaScriptのコード込みで説明します。在庫切れの選択肢に斜線を引く処理、そして「Sサイズの赤だけ商品として存在しない」という組み合わせの分岐まで含めて、今日中に動く状態まで持っていける構成にしました。


前提と作業環境の確認

前提と作業環境の確認

対象テーマとバージョン

この記事の実装は次の環境を前提にしています。

項目 前提
テーマ Online Store 2.0 対応テーマ(Dawn 系のディレクトリ構成)
編集場所 sections/ snippets/ assets/ が存在すること
商品データ オプションが1〜3個
想定オプション名 「色」「カラー」「Color」のいずれか

Dawn 系のテーマであれば snippets/product-variant-picker.liquid が存在します。テーマによってファイル名が product-form.liquid だったり、セクション内に直書きされていたりします。まず variant で検索して、select タグまたは input type="radio" を出力している箇所を特定してください。この記事ではその箇所を丸ごと差し替える方針をとります。

なお、商品ごとに設定できるオプションの個数はShopifyの仕様として上限があり、プランや時期によって変わります。3個を超えるオプションを扱う予定があるなら、着手前に管理画面の商品編集画面で実際に追加できる個数を確認してください。この記事のコードはオプション数に依存しない書き方をしているので、上限が変わっても判定ロジックはそのまま使えます。

作業前に必ずやること

テーマのバックアップです。Shopify管理画面の「オンラインストア」→「テーマ」から、対象テーマの「複製」を実行してから作業してください。ローカルで Shopify CLI を使う場合は shopify theme dev で開発用テーマに接続し、本番テーマには直接 push しないようにします。

複製したテーマは名前に日付を入れておくと戻すときに迷いません。「Dawn 2026-09-06 バリエーション改修前」のように、何の直前の状態かまで書いておくのが確実です。テーマの保存本数には上限があるので、作業が終わったら古い控えは整理します。

バリエーション表示を差し替えるまでの工程
テーマ複製本番を触らない状態を先に作る
出力箇所の特定selectを吐いているsnippetを探す
Liquid差し替えラジオ+labelの構造に置き換える
CSSとJS追加見た目と在庫判定を足す

工程は4つだけです。JavaScriptの追加まで終わってから在庫切れの表示が効くので、途中で見た目が崩れても最後まで進めてください。

バリエーション選択の基本構造

Shopifyのバリエーションは「オプションの組み合わせ」で成立しています。色3種類×サイズ4種類なら理論上12通りですが、実際に商品として登録されているバリエーションだけが購入可能です。ここが実装上の分岐点になります。

  • product.options_with_values … オプション名と選択肢の一覧
  • product.variants … 実在するバリエーションの配列。available で在庫の有無がわかる
  • product.selected_or_first_available_variant … 初期選択されるバリエーション

セレクトボックスをボタンに置き換えるだけなら Liquid の書き換えで完結しますが、「存在しない組み合わせ」と「在庫切れ」を出し分けるには、product.variants の情報を JavaScript 側に渡す必要があります。

ここで押さえておきたいのは、options_with_values が返す選択肢は「その商品に登録されているバリエーションから逆算された値の集合」だという点です。赤・青・黒の3色を登録していても、赤のSサイズしか作っていなければ、サイズの選択肢としてMやLは出てきません。逆に、赤M・青S・黒Lの3バリエーションだけを登録した商品では、色3つ×サイズ3つの9マスが選択肢として描画され、そのうち6マスは「存在しない組み合わせ」になります。この6マスをどう見せるかがこの記事の主題です。


セレクトボックスをボタンに置き換えるLiquid

セレクトボックスをボタンに置き換えるLiquid

置き換え先のマークアップ

snippets/ に新しいファイルを作ります。ファイルパスは snippets/ts-variant-picker.liquid です。既存のテーマファイルを直接書き換えるより、新規スニペットを作って呼び出しを差し替えるほうが、元に戻すときに楽になります。

{% comment %}
  ts-variant-picker.liquid
  呼び出し: {% render 'ts-variant-picker', product: product, section_id: section.id %}
{% endcomment %}

{%- liquid
  assign current_variant = product.selected_or_first_available_variant
  assign color_keys = 'color,colour,カラー,色' | split: ','
-%}

<variant-picker
  class="ts-vp"
  data-section="{{ section_id }}"
  data-url="{{ product.url }}"
>
  <script type="application/json" data-variant-json>
    [
      {%- for variant in product.variants -%}
        {
          "id": {{ variant.id }},
          "available": {{ variant.available }},
          "options": {{ variant.options | json }},
          "price": {{ variant.price }}
        }{%- unless forloop.last -%},{%- endunless -%}
      {%- endfor -%}
    ]
  </script>

  {%- for option in product.options_with_values -%}
    {%- liquid
      assign name_down = option.name | downcase | strip
      assign is_color = false
      for key in color_keys
        assign k = key | strip
        if name_down == k
          assign is_color = true
        endif
      endfor
    -%}

    <fieldset class="ts-vp__group" data-option-index="{{ forloop.index0 }}">
      <legend class="ts-vp__legend">
        {{ option.name }}
        <span class="ts-vp__selected" data-selected-for="{{ forloop.index0 }}">
          {{ option.selected_value }}
        </span>
      </legend>

      <div class="ts-vp__list{% if is_color %} ts-vp__list--swatch{% endif %}">
        {%- for value in option.values -%}
          {%- assign input_id = section_id | append: '-opt' | append: option.position | append: '-' | append: forloop.index -%}
          <input
            type="radio"
            class="ts-vp__input"
            id="{{ input_id }}"
            name="{{ section_id }}-option-{{ option.position }}"
            value="{{ value | escape }}"
            data-option-index="{{ forloop.parentloop.index0 }}"
            {% if value == option.selected_value %}checked{% endif %}
          >
          <label
            class="ts-vp__label{% if is_color %} ts-vp__label--swatch{% endif %}"
            for="{{ input_id }}"
            {% if is_color %}style="--swatch: {{ value | handle | replace: '-', ' ' }};"{% endif %}
          >
            <span class="ts-vp__text">{{ value }}</span>
          </label>
        {%- endfor -%}
      </div>
    </fieldset>
  {%- endfor -%}

  <select name="id" class="ts-vp__native" hidden>
    {%- for variant in product.variants -%}
      <option
        value="{{ variant.id }}"
        {% if variant == current_variant %}selected{% endif %}
        {% unless variant.available %}disabled{% endunless %}
      >{{ variant.title }}</option>
    {%- endfor -%}
  </select>
</variant-picker>

変更が必要な箇所

コピーしたあと、次の3点は必ずストアに合わせて調整します。

箇所 何を直すか
color_keys の中身 オプション名が「ボディカラー」など独自名なら追加する。完全一致で判定しているため表記ゆれに注意
ts-vp の接頭辞 テーマ既存のクラス名と衝突しないなら変えなくてよい。衝突が心配なら全置換する
--swatch の値 この段階では CSS の色名として渡している。実務ではメタフィールド参照に置き換える(後述)

<select name="id">hidden で残しているのは、フォーム送信時のバリエーションIDをこの要素が担うためです。Shopifyのカート追加は name="id" の値を見ているので、ここを消すと商品が追加できなくなります。ラジオボタンはあくまで見た目の操作で、実際の値は隠したselectが持つ、という二段構えです。

input_id はオプション名ではなく option.position から組み立てています。オプション名に日本語を使っているストアで | handle を通すと空文字になり、複数のラベルが同じIDを指してクリックが効かなくなるためです。この点は後の「うまくいかないとき」でも扱います。

呼び出し側の差し替え

sections/main-product.liquid を開き、既存のバリエーション出力を呼んでいる行を探します。Dawn 系なら次のような行です。

{%- render 'product-variant-picker', product: product, block: block, product_form_id: product_form_id -%}

これを次に置き換えます。

{%- render 'ts-variant-picker', product: product, section_id: section.id -%}

元の行はコメントアウトで残しておくと、切り戻しが一手で済みます。ここまでで、ブラウザ上にはスタイルの当たっていないラジオボタンが並んでいるはずです。


カラースウォッチと在庫切れのCSS

カラースウォッチと在庫切れのCSS

スタイルシートを追加する

assets/ts-variant-picker.css を新規作成します。

.ts-vp__group {
  border: 0;
  margin: 0 0 1.5rem;
  padding: 0;
}

.ts-vp__legend {
  display: block;
  font-size: 0.875rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  margin-bottom: 0.625rem;
  padding: 0;
}

.ts-vp__selected {
  color: rgb(0 0 0 / 0.55);
  font-weight: 400;
  margin-left: 0.5em;
}

.ts-vp__list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

/* ラジオ本体は視覚的に隠すが、キーボード操作は残す */
.ts-vp__input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* 通常のボタン型 */
.ts-vp__label {
  --ts-vp-border: rgb(0 0 0 / 0.22);
  align-items: center;
  border: 1px solid var(--ts-vp-border);
  border-radius: 2px;
  cursor: pointer;
  display: inline-flex;
  font-size: 0.9375rem;
  justify-content: center;
  min-height: 2.75rem;
  min-width: 2.75rem;
  padding: 0 0.875rem;
  position: relative;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.ts-vp__label:hover {
  --ts-vp-border: rgb(0 0 0 / 0.5);
}

.ts-vp__input:checked + .ts-vp__label {
  --ts-vp-border: rgb(0 0 0 / 0.9);
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.9);
}

.ts-vp__input:focus-visible + .ts-vp__label {
  outline: 2px solid #1a73e8;
  outline-offset: 2px;
}

/* カラースウォッチ型 */
.ts-vp__label--swatch {
  background-color: var(--swatch, #ccc);
  background-size: cover;
  border-radius: 50%;
  height: 2.75rem;
  min-width: 0;
  padding: 0;
  width: 2.75rem;
}

.ts-vp__label--swatch .ts-vp__text {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.ts-vp__input:checked + .ts-vp__label--swatch {
  box-shadow: 0 0 0 2px #fff inset, 0 0 0 2px rgb(0 0 0 / 0.9);
}

/* 在庫切れ: 選べるが売り切れとわかる */
.ts-vp__label.is-soldout {
  color: rgb(0 0 0 / 0.35);
  --ts-vp-border: rgb(0 0 0 / 0.12);
}

.ts-vp__label.is-soldout::after {
  background: currentColor;
  content: "";
  height: 1px;
  left: 50%;
  position: absolute;
  top: 50%;
  transform: translate(-50%, -50%) rotate(-24deg);
  width: 118%;
}

.ts-vp__label--swatch.is-soldout {
  opacity: 0.4;
}

.ts-vp__label--swatch.is-soldout::after {
  color: rgb(0 0 0 / 0.7);
  transform: translate(-50%, -50%) rotate(-45deg);
  width: 128%;
}

/* 組み合わせとして存在しない: 触れない */
.ts-vp__label.is-unavailable {
  cursor: not-allowed;
  opacity: 0.25;
  pointer-events: none;
}

theme.liquid</head> 直前に読み込みを追加します。

{{ 'ts-variant-picker.css' | asset_url | stylesheet_tag }}

変更が必要な箇所

変数・値 直す基準
min-height / min-width2.75rem ルート16px換算で44px。WCAG 2.2 のターゲットサイズ(最低限)が24×24 CSSピクセル、達成基準の強い方(AAA)が44×44なので、44を初期値にしている
outline#1a73e8 フォーカスリングの色。テーマのアクセントカラーに合わせる
rotate(-24deg) 斜線の角度。ボタンの幅が広いほど寝かせたほうが自然に見える
border-radius: 50% 丸いスウォッチが前提。四角にするなら 2px などに変える

::after の斜線は width: 118% で親のボタンより少しはみ出させています。ぴったり100%にすると角丸の内側で線が途切れて見えるためで、幅の広いボタンでは 110% 程度まで下げても成立します。

在庫切れを「隠さない」理由

在庫切れの選択肢は、非表示にせず斜線で残します。選択肢ごと消してしまうと、そのサイズが元から存在しないのか一時的な品切れなのかが判別できません。再入荷を待つ客を逃がすのと、カラー展開の全体像が伝わらないのは別の損失ですが、どちらも表示の作り方で避けられます。

一方で「組み合わせとして存在しない」ものは扱いが違います。Sサイズは黒しか作っていない商品で、Sを選んだあとに赤が斜線で残っていると、あるはずの色が売り切れているように読めてしまいます。ここは pointer-events: none で触れなくし、透明度も下げて背景に沈めます。

在庫切れと組み合わせ不在の見せ分け

在庫切れ(is-soldout)

  • 斜線を引いて残す
  • クリックはできる
  • 選ぶと「売り切れ」表示に変わる
  • 再入荷通知への導線を置ける

組み合わせ不在(is-unavailable)

  • 薄くして沈める
  • クリックできない
  • そもそも商品化していない
  • 説明も通知も不要

同じ「選べない」でも、客が次にとる行動が違うので表示を分けます。在庫切れなら再入荷を待つか通知を登録する、組み合わせ不在なら別のサイズか別の色に移る。表示を一緒くたにすると、客はどちらの行動をとればいいか判断できず、結果として「この色のSは入荷しますか」という、答えが最初から決まっている問い合わせが増えます。


在庫判定と組み合わせ分岐のJavaScript

在庫判定と組み合わせ分岐のJavaScript

カスタム要素として実装する

assets/ts-variant-picker.js を新規作成します。外部ライブラリは使いません。

class VariantPicker extends HTMLElement {
  connectedCallback() {
    const json = this.querySelector('[data-variant-json]');
    this.variants = JSON.parse(json.textContent);
    this.nativeSelect = this.querySelector('.ts-vp__native');
    this.inputs = Array.from(this.querySelectorAll('.ts-vp__input'));
    this.sectionId = this.dataset.section;

    this.addEventListener('change', this.onChange.bind(this));
    this.refresh();
  }

  /** 現在選択されている値を配列で返す ["赤", "M"] */
  get selectedOptions() {
    const result = [];
    this.querySelectorAll('.ts-vp__group').forEach((group) => {
      const checked = group.querySelector('.ts-vp__input:checked');
      result.push(checked ? checked.value : null);
    });
    return result;
  }

  onChange() {
    this.refresh();
    this.updateSelectedLabels();
    this.syncNativeSelect();
    this.updateUrl();
    this.updatePrice();
  }

  /**
   * 各選択肢に is-soldout / is-unavailable を振り直す。
   * 判定は「自分以外の選択を固定したとき、その値を含むバリエーションが在るか」で行う。
   */
  refresh() {
    const selected = this.selectedOptions;

    this.inputs.forEach((input) => {
      const index = Number(input.dataset.optionIndex);
      const label = this.querySelector(`label[for="${input.id}"]`);
      if (!label) return;

      // 自分の位置だけこの値に差し替えた組み合わせを作る
      const probe = selected.slice();
      probe[index] = input.value;

      const matches = this.variants.filter((v) =>
        probe.every((val, i) => val === null || v.options[i] === val)
      );

      const exists = matches.length > 0;
      const inStock = matches.some((v) => v.available);

      label.classList.toggle('is-unavailable', !exists);
      label.classList.toggle('is-soldout', exists && !inStock);
      label.setAttribute(
        'aria-label',
        exists && !inStock ? `${input.value}(売り切れ)` : input.value
      );
      input.disabled = !exists;
    });
  }

  /** 現在の組み合わせに一致するバリエーションを返す(無ければ null) */
  get currentVariant() {
    const selected = this.selectedOptions;
    return (
      this.variants.find((v) =>
        selected.every((val, i) => v.options[i] === val)
      ) || null
    );
  }

  updateSelectedLabels() {
    this.querySelectorAll('.ts-vp__group').forEach((group) => {
      const index = group.dataset.optionIndex;
      const checked = group.querySelector('.ts-vp__input:checked');
      const out = this.querySelector(`[data-selected-for="${index}"]`);
      if (out && checked) out.textContent = checked.value;
    });
  }

  syncNativeSelect() {
    const variant = this.currentVariant;
    const button = document.querySelector(`#ProductSubmitButton-${this.sectionId}`)
      || document.querySelector('[name="add"]');

    if (!variant) {
      if (button) {
        button.disabled = true;
        const text = button.querySelector('span');
        if (text) text.textContent = 'この組み合わせは取り扱いがありません';
      }
      return;
    }

    this.nativeSelect.value = variant.id;
    this.nativeSelect.dispatchEvent(new Event('change', { bubbles: true }));

    if (button) {
      button.disabled = !variant.available;
      const text = button.querySelector('span');
      if (text) {
        text.textContent = variant.available ? 'カートに追加する' : '売り切れ';
      }
    }
  }

  updateUrl() {
    const variant = this.currentVariant;
    if (!variant) return;
    const url = `${this.dataset.url}?variant=${variant.id}`;
    window.history.replaceState({}, '', url);
  }

  updatePrice() {
    const variant = this.currentVariant;
    const target = document.querySelector(`#price-${this.sectionId}`);
    if (!variant || !target) return;

    const formatted = (variant.price / 100).toLocaleString('ja-JP', {
      style: 'currency',
      currency: 'JPY',
    });
    const out = target.querySelector('.price-item--regular') || target;
    out.textContent = formatted;
  }
}

if (!customElements.get('variant-picker')) {
  customElements.define('variant-picker', VariantPicker);
}

theme.liquid</body> 直前、または main-product.liquid の末尾に読み込みを追加します。

<script src="{{ 'ts-variant-picker.js' | asset_url }}" defer></script>

変更が必要な箇所

箇所 何を直すか
#ProductSubmitButton-${this.sectionId} テーマによってIDが違う。開発者ツールで name="add" を持つボタンのIDを確認する
#price-${this.sectionId} 価格表示のIDも同様。Dawn 系はこの形だが、要確認
ボタンの文言 「カートに追加する」「売り切れ」はテーマの翻訳ファイル(locales/ja.json)から引くほうが望ましい
currency: 'JPY' 多通貨ストアなら Shopify.currency.active を参照する形に変える
price / 100 Shopifyの価格は最小通貨単位の整数。円のように小数を持たない通貨でも100倍で入るのでこの割り算でよいが、money フィルタで出した文字列を使う手もある

判定ロジックの考え方

refresh() がこの実装の中心です。やっていることは1つで、「いま選ばれている他のオプションを固定したまま、この選択肢に切り替えたら、実在するバリエーションに当たるか」を全選択肢ぶん試しています。

たとえば「赤・M」が選ばれている状態で、サイズSのラベルを判定するときは ["赤", "S"] という組み合わせで product.variants を検索します。ヒットしなければ組み合わせ不在、ヒットしても全部 available: false なら在庫切れです。

この方式の利点は、オプションが1個でも3個でも同じコードで動くことです。probe 配列の長さがオプション数に自動で追従します。3オプションの商品でも every() が全次元を比較するので、条件分岐を増やす必要はありません。

もう1つ、この書き方には「順序に依存しない」という性質があります。よくある実装は「1番目のオプションを選んだら2番目の候補を絞る」という一方向の連動になっていて、客が2番目から先に触ると破綻します。probe 方式は自分の位置だけを仮に差し替えて全体を照合するので、どのオプションから触っても同じ結果になります。実装量は変わらないので、最初からこちらで書いておくのが得です。

動的な色の当て方

Liquid側で --swatch: {{ value | handle }} としましたが、これは「red」「blue」のようにCSSの色名として通る値のときしか機能しません。日本語の色名や、ブランド独自のカラーコードを使う場合は次のどちらかに切り替えます。

方法A: メタフィールドで色を持たせる

Shopify管理画面で商品メタフィールドを作り、色名とカラーコードの対応表を持たせます。Liquid側は次のように書きます。

{%- liquid
  assign swatch_map = product.metafields.custom.swatch_colors.value
  assign swatch_hex = swatch_map[value] | default: '#cccccc'
-%}
<label class="ts-vp__label ts-vp__label--swatch" for="{{ input_id }}" style="--swatch: {{ swatch_hex }};">

メタフィールドの型は「JSON」を選び、{"レッド": "#c0392b", "ネイビー": "#2c3e50"} の形で保存します。名前空間とキーは custom.swatch_colors としていますが、任意の値に変えて構いません。

商品ごとにJSONを持たせると同じ色を何度も書くことになるので、色数が多いストアではショップ全体のメタフィールド(shop.metafields)に対応表を1つ置き、商品側は参照するだけにする形も取れます。どちらにするかは、色名がブランド共通か商品ごとに固有かで決まります。

方法B: バリエーション画像を背景にする

生地の質感や柄を見せたい場合は、色ベタより画像のほうが伝わります。

{%- liquid
  assign swatch_variant = product.variants | where: 'option1', value | first
  assign swatch_img = swatch_variant.image | default: product.featured_image
-%}
<label
  class="ts-vp__label ts-vp__label--swatch"
  for="{{ input_id }}"
  style="background-image: url({{ swatch_img | image_url: width: 96 }});"
>

option1 を決め打ちしているので、色が2番目のオプションなら option2 に変えます。CSS側には background-size: cover;.ts-vp__label--swatch に入れてあります(上のCSSに含めました)。

なお、Shopify にはテーマ側の標準機能としてカラースウォッチを扱う仕組みも用意されており、テーマとShopifyのバージョンによっては管理画面から色を登録できる場合があります。改修に入る前に、使っているテーマの設定画面にスウォッチ関連の項目があるかを一度見てください。標準機能で足りるなら、この記事の実装は在庫切れ表示の部分だけを足せば済みます。


アクセシビリティと動作確認

アクセシビリティと動作確認

スクリーンリーダーで意味が通るか

ラジオボタンを視覚的に隠す実装では、display: none を使わないことが要点です。display: none にすると要素がアクセシビリティツリーから外れ、キーボードでフォーカスできなくなります。上のCSSで clip-path による隠し方をしているのはそのためです。

スウォッチは色しか表示されないため、テキストラベルを .ts-vp__text として残し、視覚的にだけ隠しています。これがないとスクリーンリーダーには何も読み上げられません。

在庫切れの情報は refresh() の中で aria-label に反映しています。斜線というCSSの装飾は読み上げには乗らないので、「レッド(売り切れ)」というテキストとして別に持たせる必要があります。

fieldsetlegend を使っているのも同じ理由です。選択肢のかたまりに「色」「サイズ」という名前が付いていないと、ラジオボタンだけが脈絡なく読み上げられます。legend の中に現在の選択値(.ts-vp__selected)を入れているので、フォーカス時に「色 レッド」まで一度に伝わります。

ブラウザ対応

使っている機能 対応状況
Custom Elements v1 主要ブラウザの現行版で対応済み
clip-path: inset(50%) 同上
CSSカスタムプロパティ 同上
:focus-visible 比較的新しい機能。Safariは対応が後発だったため、古いiOSが多いストアでは要確認
Array.prototype.every / some ES5の範囲。実質どの環境でも動く

対応状況は時期によって変わるので、実装前に対象ブラウザのシェアと合わせて caniuse などで確認してください。:focus-visible に不安がある場合は、.ts-vp__input:focus + .ts-vp__label のルールを併記しておけば、古い環境でもフォーカスリングが消えることはありません。

確認すべき5つのケース

実装後、次の状態を実際に作って確認します。テストデータは開発ストアで作れます。

  1. 全バリエーション在庫あり … すべて通常表示、カートに追加できる
  2. 一部が在庫切れ … 該当ラベルに斜線、選ぶとボタンが「売り切れ」に変わる
  3. 組み合わせが存在しない … 別オプションを選んだ瞬間に薄くなり、押せない
  4. オプション1個の商品 … グループが1つだけ描画され、エラーが出ない
  5. オプション3個の商品 … 3グループすべてで判定が連動する

3番目は見落としやすい箇所です。「Sを選ぶ→赤が薄くなる」だけでなく、「赤を選ぶ→Sが薄くなる」の逆方向も動くことを確認してください。refresh() は全選択肢を毎回走査しているので、片方向だけ動かないという状況は本来起きませんが、data-option-index の値がずれていると片方だけ壊れます。

あわせて、キーボードだけで一周できるかも見ます。Tabで各グループに入り、矢印キーで選択肢を移動し、Enterでカートに追加するところまで、マウスに触れずに通せれば合格です。ラジオボタンのグループ内移動は矢印キーが標準の挙動なので、name 属性が正しくグループ化されていれば追加の実装は要りません。逆に矢印キーで移動できないなら、name が選択肢ごとに違う値になっている可能性があります。

表示速度への影響

追加したCSSとJavaScriptは外部ライブラリの読み込みを伴わず、バリエーションのJSONもページ内に埋め込んでいるため、選択のたびにAPIを叩く通信は発生しません。ここが、同種の機能をアプリで入れた場合との一番大きな差になります。

バリエーション件数が多い商品ではJSONそのものがHTMLを膨らませます。その場合は price を落として id available options だけにすれば削れます。価格の切り替えが不要な商品(全バリエーション同一価格)なら、updatePrice() ごと外してしまうのが早いです。実際に何KB増えたかは、実装の前後で商品ページのHTMLサイズを比べれば数字で出せます。

Shopifyテーマの実装で外部ライブラリを避ける考え方については、スライダーを素のCSSで組む記事でも同じ方針をとっています。


うまくいかないとき

うまくいかないとき

カートに追加できない

<select name="id"> が消えているか、hidden ではなく display: none で隠されている可能性があります。Shopifyのフォーム送信は name="id" の値を読むので、この要素が生きていることが前提です。開発者ツールでフォームを選択し、document.querySelector('[name="id"]').value を実行して、バリエーションIDが返るか確認してください。

もう1つ多いのが、syncNativeSelect()dispatchEvent が効いていないケースです。テーマ側が独自の変更監視をしている場合、change イベントではなく variant:change のようなカスタムイベントを待っていることがあります。テーマの assets/global.jsaddEventListener で検索して、何を待っているか確認します。

スウォッチの色が全部グレーになる

--swatch に渡している値が、CSSの色として解釈できていません。| handle フィルタは「レッド」のような日本語を空文字に近い形へ変換してしまうため、日本語のオプション値では確実に失敗します。上に書いたメタフィールド方式に切り替えてください。

暫定対応としては、Liquid側に対応表をベタ書きする方法もあります。

{%- liquid
  case value
    when 'レッド'
      assign swatch_hex = '#c0392b'
    when 'ネイビー'
      assign swatch_hex = '#2c3e50'
    when 'アイボリー'
      assign swatch_hex = '#f2ede3'
    else
      assign swatch_hex = '#cccccc'
  endcase
-%}

商品が増えると破綻するので、恒久的な運用にはしません。

在庫切れの判定が実態と合わない

variant.available は「在庫があるか、または在庫を追跡していないか」を返します。在庫追跡をオフにしている商品は、在庫数がゼロでも availabletrue になります。管理画面の「在庫を追跡する」にチェックが入っているか確認してください。

在庫数そのものを見たい場合は variant.inventory_quantity を使いますが、この値は在庫追跡が有効なときのみ意味を持ちます。「残りわずか」を出したいときは次のように条件を足します。

{
  "id": {{ variant.id }},
  "available": {{ variant.available }},
  "options": {{ variant.options | json }},
  "qty": {{ variant.inventory_quantity | default: 0 }},
  "policy": {{ variant.inventory_policy | json }}
}

inventory_policy"continue" なら在庫切れでも購入を許可する設定です。この場合、在庫数がマイナスでも availabletrue になります。取り寄せ商品を扱うストアでは、この設定と「残りわずか」の表示を両方使うと、在庫がマイナスなのに「残り1点」と出るような矛盾が起きます。policy を見て、continue のときは在庫数の表示自体を出さない分岐を入れてください。

ラベルをクリックしても選択が変わらない

<label for="...">for<input id="...">id が一致していません。IDにオプション名の日本語をそのまま入れて | handle を通すと空になり、複数のラベルが同じIDを指してしまいます。この記事のコードで input_idoption.position から組み立てているのはこれを避けるためです。

自作のテーマや他所からコピーしたコードで、まだオプション名ベースになっている場合は次の形に直します。

{%- assign input_id = section_id | append: '-opt' | append: option.position | append: '-' | append: forloop.index -%}

日本語のオプション名を使うストアではこの形を推奨します。生成されたIDは開発者ツールで実際に確認し、同じIDが2つ以上出ていないかを見てください。

商品ページ以外で表示が崩れる

クイックビューやカートドロワー内で商品フォームを描画しているテーマだと、同じ section_id が複数箇所に出て name 属性が重複します。ラジオボタンは name でグループ化されるため、重複すると別の商品の選択肢と連動してしまいます。

section_id の代わりに商品IDを混ぜると回避できます。

{%- assign uid = section_id | append: '-' | append: product.id -%}

以降の nameid をこの uid ベースに書き換えてください。同じ商品が同じページに2回出るケース(おすすめ商品にその商品自身が混ざるなど)まで潰したいなら、forloop の親インデックスも足します。

テーマエディタで変更が反映されない

Shopifyのテーマエディタはセクションを部分的に再描画します。カスタム要素の connectedCallback は再描画のたびに走るので通常は問題ありませんが、イベントリスナーを document に付けていると多重登録されます。この実装では this.addEventListener としているため、要素ごと差し替われば古いリスナーも一緒に消えます。

テーマエディタ専用の再初期化が必要な場合は、次を追加します。

document.addEventListener('shopify:section:load', (event) => {
  const picker = event.target.querySelector('variant-picker');
  if (picker && !picker.variants) picker.connectedCallback();
});

運用の設計まで含めて考える

運用の設計まで含めて考える

誰が色を登録するのか

カラースウォッチをメタフィールドで持たせる方式は実装としては素直ですが、新商品を登録するたびに担当者がカラーコードを入力する運用が発生します。ここを決めずに納品すると、半年後には対応表に載っていない色が増えてスウォッチがグレーで並ぶことになります。

対策は2つあります。1つは商品登録のマニュアルにカラーコード入力の手順を含めること。もう1つは、色名の候補をあらかじめ固定し、メタフィールドの定義を「単一行のテキスト(選択肢のリスト)」にして自由入力を許さないことです。後者のほうが破綻しにくいですが、新色を出すたびに定義の更新が必要になります。ストアの新商品ペースで選びます。

判断の目安はこうです。新色が年に数回しか増えないアパレル以外の商材なら、選択肢を固定して自由入力を止める。シーズンごとに色名が入れ替わるアパレルなら、自由入力を許したうえで「未登録の色はグレーで出る」ことを納品時に伝え、定期的に対応表を見直す運用にする。どちらにしても、決めずに渡すのが一番悪い結果になります。

実装のボリュームをどう見積もるか

この改修の工数は、テーマの素性でほぼ決まります。見積もりの前に確認すべきなのは次の3点です。

  • 既存テーマがバリエーション出力をどこに書いているか(独立したスニペットか、セクション直書きか)
  • 商品ページ以外でも商品フォームを描画しているか(クイックビュー、カートドロワー、おすすめ商品)
  • 色の対応表を新規に作る必要があるか、すでにメタフィールドがあるか

スニペットが独立していて商品ページだけで完結し、色の対応表も既にあるストアなら、差し替えは短時間で終わります。逆に、セクションに直書きされたテーマで、クイックビューにも同じフォームが出て、色の対応表をゼロから作るとなると、作業量は数倍に膨らみます。金額を出す前にこの3点を確認しておくと、着手後にブレません。

テーマカスタマイズの限界を先に把握する

バリエーション表示は Liquid とフロントエンドで完結する領域なので、この記事の方法で自由に作り込めます。一方でShopifyには、テーマの改修では超えられない仕様上の制約もあります。着手前にどこまでできるかを把握しておくと、見積もりのブレが減ります。

商品ページのURL構造やパラメータの扱いについては、ShopifyのSEO対策|URL構造の制約とその回避策も参考になります。?variant= のパラメータをどう扱うかは、この実装の updateUrl() に直接関わります。

テーマ選定の段階から関わる場合は、既存テーマの構造がどこまで改修に耐えるかを見ておく必要があります。バリエーション周りの実装は特に、テーマごとの差が大きい部分です。Shopifyテーマの選び方|無料・有料・オリジナルの判断基準で、判断の軸を整理しています。

【画像挿入: 実装後の商品ページ。丸いカラースウォッチが横並びになり、そのうち1つに斜線が入っている状態のスクリーンショット】


よくある質問

よくある質問

Dawn以外のテーマでもこの実装は使えますか

Online Store 2.0 対応テーマであれば構造は共通しているので使えます。ただしカートボタンや価格表示のセレクタはテーマごとに異なるため、syncNativeSelect()updatePrice() の中のID指定は必ず実物を見て書き換えてください。sections/snippets/ ディレクトリがあるかどうかが、2.0対応の目安になります。

在庫切れの選択肢は非表示にしたほうがいいですか

再入荷の予定がある商品なら、斜線で残すことを推奨します。非表示にすると、そのサイズが元から存在しないのか品切れなのかが読者に伝わりません。一方で廃番になった色は、管理画面でバリエーション自体を削除するほうが表示も運用もすっきりします。

オプションが3つある商品でも動きますか

動きます。判定ロジックは選択中の値を配列として扱い、オプション数に合わせて長さが変わる作りになっています。3オプションの商品では組み合わせの総数が増えるぶん「存在しない組み合わせ」も増えるので、is-unavailable の表示が正しく出るかを実データで確認してください。

アプリを使わずに実装する意味はありますか

読み込むJavaScriptが増えないぶん、表示速度に効きます。バリエーション表示のアプリは多くが独自のスクリプトとCSSを追加するため、商品ページの読み込みが重くなりがちです。この実装はテーマの一部として動くのでアプリの月額費用も発生しません。ただし保守は自社側の責任になり、テーマを新しいバージョンに載せ替えるときは手作業での移植が必要です。

変更したURLパラメータはSEOに影響しますか

?variant= のパラメータは Shopify が標準で扱うもので、商品ページの canonical はパラメータなしのURLに向くのが通常です。history.replaceState で書き換えているのは表示上のURLだけなので、インデックスへの影響は通常ありません。テーマによって canonical の出力が異なる場合があるので、対象ページのソースを実物で確認してください。


まとめ

Shopify の商品オプションのバリエーション表示は、Liquid・CSS・JavaScriptの3ファイルで作り込めます。要点を整理します。

  • 隠した <select name="id"> を残し、ラジオボタンは見た目の操作に徹する
  • 在庫切れは斜線で残し、存在しない組み合わせは触れなくして沈める
  • 判定は「他の選択を固定してこの値に切り替えたら実在するか」の1つのロジックで統一する
  • 色はメタフィールドで持たせ、日本語の値を | handle に通さない
  • ラジオボタンは display: none ではなく clip-path で隠す
  • IDはオプション名ではなく option.position から組み立てる

セレクトボックスのままでも購入はできます。ただし色を色として見せられるかどうかは、商品の見え方そのものに関わります。着手のハードルは低い一方で、テーマの素性によって工数は変わるので、上に挙げた3点の確認から始めるのが確実です。