テクニック

Shopify カート ドロワー カスタマイズ完全ガイド|追従と再計算の実装

この記事の対象: Dawn系テーマのカート周りを触るShopify制作者
読了時間: 約12分

商品をカートに入れるたびにカートページへ飛ばされる。戻るボタンで一覧に帰ってきたら、スクロール位置は先頭。この往復が2回続くと、購入を決めていた人でも手が止まります。

ページ遷移せず横から出るカートドロワーは、この往復をゼロにする実装です。ただ「横から出す」だけなら難しくありませんが、実務で困るのはその先にあります。数量を変えたときの金額の再計算、送料無料まであといくらの表示、開いている間のスクロール制御、そして通信が失敗したときの戻し方。この記事では、Dawn系テーマにドロワーを組み込み、その周辺まで含めて動く状態にするところまでを、コードを省略せずに書きます。


前提と、触るファイルの確認

前提と、触るファイルの確認

必要な環境

項目 条件
テーマ Online Store 2.0 対応テーマ(Dawn および Dawn 系の派生テーマ)
権限 テーマの編集権限(themes の read/write)
作業環境 Shopify CLI、またはテーマエディタのコードエディタ
ブラウザ Chrome / Edge / Safari / Firefox の各最新版
外部ライブラリ 不要(素の JavaScript のみで完結します)

Dawn には最初からカートドロワーの仕組みが入っています。テーマエディタの テーマ設定 → カート → カートの種類 で「ドロワー」を選べば、基本の開閉自体は動きます。ここで扱うのは、その標準機能では届かない部分——再計算の見せ方、送料無料バーの追加、エラー時の挙動、そして標準機能を持たない自作テーマへの組み込みです。

触るファイル

作業前に、以下のファイルがテーマ内にあるかを確認してください。Dawn のバージョンによってファイル名が異なる場合があります。

カートドロワーに関係するファイルの役割

snippets/

  • cart-drawer.liquid — ドロワー本体のマークアップ
  • ts-cart-progress.liquid — 送料無料バー(新規作成)

assets/

  • cart-drawer.js — 開閉と数量変更の制御
  • ts-cart-drawer.css — ドロワーの見た目(新規作成)

sections/

  • cart-drawer.liquid — セクションとしての読み込み口

layout/theme.liquid

既存ファイルを直接書き換えず、ts- を付けた新規ファイルに寄せると、テーマ更新時の衝突を避けられます。

Dawn 本体のファイルを直接編集すると、テーマをアップデートしたときに変更が消えます。この記事では追加分を ts- プレフィックスの新規ファイルに置き、既存ファイルへの手入れは最小限にとどめる方針で進めます。ts- は好きな文字列に変えてかまいませんが、案件内では統一してください。


ドロワー本体を組む

ドロワー本体を組む

マークアップ

snippets/ts-cart-drawer.liquid を新規作成し、以下をそのまま貼ります。

{%- comment -%} snippets/ts-cart-drawer.liquid {%- endcomment -%}
<ts-cart-drawer
  id="TsCartDrawer"
  class="ts-cart-drawer"
  data-threshold="{{ settings.ts_free_shipping_threshold | default: 10000 }}"
  hidden
>
  <div class="ts-cart-drawer__overlay" data-ts-close></div>

  <div
    class="ts-cart-drawer__panel"
    role="dialog"
    aria-modal="true"
    aria-labelledby="TsCartDrawerTitle"
    tabindex="-1"
  >
    <header class="ts-cart-drawer__head">
      <h2 id="TsCartDrawerTitle" class="ts-cart-drawer__title">
        カート
        <span class="ts-cart-drawer__count" data-ts-count>{{ cart.item_count }}</span>
      </h2>
      <button
        type="button"
        class="ts-cart-drawer__close"
        data-ts-close
        aria-label="カートを閉じる"
      >
        <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true" focusable="false">
          <path d="M4 4 L16 16 M16 4 L4 16" stroke="currentColor" stroke-width="1.5" fill="none"/>
        </svg>
      </button>
    </header>

    <div class="ts-cart-drawer__body" data-ts-body>
      {%- if cart.item_count == 0 -%}
        <div class="ts-cart-drawer__empty">
          <p>カートに商品がありません。</p>
          <a href="{{ routes.all_products_collection_url }}" class="ts-cart-drawer__link">
            商品を見る
          </a>
        </div>
      {%- else -%}
        {%- render 'ts-cart-progress', cart: cart -%}

        <ul class="ts-cart-drawer__items" data-ts-items>
          {%- for item in cart.items -%}
            <li class="ts-cart-item" data-ts-line="{{ forloop.index }}" data-ts-key="{{ item.key }}">
              <a href="{{ item.url }}" class="ts-cart-item__media">
                {%- if item.image -%}
                  <img
                    src="{{ item.image | image_url: width: 160 }}"
                    srcset="{{ item.image | image_url: width: 160 }} 1x, {{ item.image | image_url: width: 320 }} 2x"
                    alt="{{ item.image.alt | escape }}"
                    width="80" height="80" loading="lazy"
                  >
                {%- endif -%}
              </a>

              <div class="ts-cart-item__info">
                <a href="{{ item.url }}" class="ts-cart-item__name">{{ item.product.title }}</a>

                {%- unless item.product.has_only_default_variant -%}
                  <p class="ts-cart-item__variant">{{ item.variant.title }}</p>
                {%- endunless -%}

                <p class="ts-cart-item__price" data-ts-line-price>
                  {{ item.final_line_price | money }}
                </p>

                <div class="ts-cart-item__qty">
                  <button
                    type="button" class="ts-qty__btn"
                    data-ts-qty="down" data-ts-line="{{ forloop.index }}"
                    aria-label="{{ item.product.title | escape }}の数量を減らす"
                  >−</button>

                  <input
                    class="ts-qty__input"
                    type="number" inputmode="numeric"
                    name="updates[]" value="{{ item.quantity }}"
                    min="0" step="1"
                    data-ts-qty-input data-ts-line="{{ forloop.index }}"
                    aria-label="{{ item.product.title | escape }}の数量"
                  >

                  <button
                    type="button" class="ts-qty__btn"
                    data-ts-qty="up" data-ts-line="{{ forloop.index }}"
                    aria-label="{{ item.product.title | escape }}の数量を増やす"
                  >+</button>
                </div>

                <button
                  type="button" class="ts-cart-item__remove"
                  data-ts-remove data-ts-line="{{ forloop.index }}"
                >削除</button>
              </div>
            </li>
          {%- endfor -%}
        </ul>
      {%- endif -%}
    </div>

    {%- if cart.item_count > 0 -%}
      <footer class="ts-cart-drawer__foot">
        <div class="ts-cart-drawer__subtotal">
          <span>小計</span>
          <strong data-ts-subtotal>{{ cart.total_price | money }}</strong>
        </div>
        <p class="ts-cart-drawer__note">送料と税は購入手続き画面で計算されます。</p>
        <form action="{{ routes.cart_url }}" method="post" novalidate>
          <button type="submit" name="checkout" class="ts-cart-drawer__checkout">
            購入手続きへ
          </button>
        </form>
        <a href="{{ routes.cart_url }}" class="ts-cart-drawer__viewcart">カートページを見る</a>
      </footer>
    {%- endif -%}
  </div>

  <p class="ts-cart-drawer__live" data-ts-live role="status" aria-live="polite"></p>
</ts-cart-drawer>

変更する箇所

  • data-thresholddefault: 10000 — 送料無料になる金額です。日本円は最小単位が円なのでそのまま 10000 と書きますが、通貨によっては下2桁が小数点以下になります(後述)
  • ts-cart-drawer のクラス名すべて — テーマのCSS設計に合わせて変えてかまいません。ただし data-ts-* 属性はJavaScript側と対応しているので、変えるなら両方そろえてください
  • 「購入手続きへ」「カートページを見る」の文言 — 多言語対応するなら {{ 'sections.cart.checkout' | t }} のようにロケールファイルの翻訳キーへ置き換えます

data-ts-line に入れているのは item.key ではなく forloop.index です。Cart AJAX API の /cart/change.jsline パラメータに1始まりの行番号を受け取れます。id にキーを渡す方法もありますが、同じバリアントが別プロパティで複数行に分かれているとき、行番号のほうが確実に狙った行だけを動かせます。

読み込み口を作る

snippets/ は自動では読まれません。layout/theme.liquid</body> の直前に1行足します。

{%- comment -%} layout/theme.liquid の </body> 直前 {%- endcomment -%}
    {%- render 'ts-cart-drawer' -%}
  </body>
</html>

CSSとJSは <head> 内で読みます。同じく layout/theme.liquid</head> の手前です。

{{ 'ts-cart-drawer.css' | asset_url | stylesheet_tag }}
<script src="{{ 'ts-cart-drawer.js' | asset_url }}" defer></script>

defer を付けているのは、HTMLの解析を止めずに読み込みつつ、DOM構築後に実行させるためです。カスタム要素の定義は defer で問題ありません。


CSS:横から出す動きと、開いている間の制御

CSS:横から出す動きと、開いている間の制御

assets/ts-cart-drawer.css を新規作成します。

/* assets/ts-cart-drawer.css */

ts-cart-drawer {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: block;
  pointer-events: none;
}

ts-cart-drawer[hidden] {
  display: none;
}

.ts-cart-drawer__overlay {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.4);
  opacity: 0;
  transition: opacity 0.28s ease;
}

.ts-cart-drawer__panel {
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  width: min(420px, 100%);
  max-width: 100%;
  display: flex;
  flex-direction: column;
  background: #fff;
  box-shadow: -8px 0 32px rgb(0 0 0 / 0.12);
  transform: translateX(100%);
  transition: transform 0.32s cubic-bezier(0.32, 0.72, 0, 1);
  overscroll-behavior: contain;
}

/* is-open は JS が付ける */
ts-cart-drawer.is-open {
  pointer-events: auto;
}
ts-cart-drawer.is-open .ts-cart-drawer__overlay {
  opacity: 1;
}
ts-cart-drawer.is-open .ts-cart-drawer__panel {
  transform: translateX(0);
}

/* 動きを減らす設定を尊重する */
@media (prefers-reduced-motion: reduce) {
  .ts-cart-drawer__overlay,
  .ts-cart-drawer__panel {
    transition-duration: 0.01ms;
  }
}

/* --- 中身のレイアウト --- */

.ts-cart-drawer__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 20px;
  border-bottom: 1px solid #e6e6e6;
  flex: 0 0 auto;
}

.ts-cart-drawer__title {
  margin: 0;
  font-size: 1rem;
  letter-spacing: 0.04em;
}

.ts-cart-drawer__count {
  display: inline-block;
  min-width: 1.6em;
  margin-inline-start: 6px;
  padding: 2px 6px;
  border-radius: 999px;
  background: #111;
  color: #fff;
  font-size: 0.75rem;
  text-align: center;
}

.ts-cart-drawer__close {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
}

.ts-cart-drawer__body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 20px;
  -webkit-overflow-scrolling: touch;
}

.ts-cart-drawer__items {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 20px;
}

.ts-cart-item {
  display: grid;
  grid-template-columns: 80px 1fr;
  gap: 14px;
  transition: opacity 0.2s ease;
}

.ts-cart-item.is-loading {
  opacity: 0.45;
  pointer-events: none;
}

.ts-cart-item__media img {
  display: block;
  width: 80px;
  height: 80px;
  object-fit: cover;
  border-radius: 4px;
  background: #f4f4f4;
}

.ts-cart-item__name {
  display: block;
  font-size: 0.9rem;
  line-height: 1.5;
  color: inherit;
  text-decoration: none;
}

.ts-cart-item__variant {
  margin: 4px 0 0;
  font-size: 0.78rem;
  color: #767676;
}

.ts-cart-item__price {
  margin: 6px 0 10px;
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
}

.ts-cart-item__qty {
  display: inline-flex;
  align-items: center;
  border: 1px solid #d5d5d5;
  border-radius: 4px;
  overflow: hidden;
}

.ts-qty__btn {
  width: 34px;
  height: 34px;
  border: 0;
  background: none;
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

.ts-qty__input {
  width: 44px;
  height: 34px;
  border: 0;
  border-inline: 1px solid #d5d5d5;
  text-align: center;
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  -moz-appearance: textfield;
  appearance: textfield;
}

.ts-qty__input::-webkit-outer-spin-button,
.ts-qty__input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.ts-cart-item__remove {
  display: block;
  margin-top: 8px;
  padding: 0;
  border: 0;
  background: none;
  font-size: 0.78rem;
  color: #767676;
  text-decoration: underline;
  cursor: pointer;
}

.ts-cart-drawer__foot {
  flex: 0 0 auto;
  padding: 20px;
  padding-bottom: max(20px, env(safe-area-inset-bottom));
  border-top: 1px solid #e6e6e6;
  background: #fff;
}

.ts-cart-drawer__subtotal {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-bottom: 4px;
  font-size: 0.95rem;
}

.ts-cart-drawer__subtotal strong {
  font-size: 1.15rem;
  font-variant-numeric: tabular-nums;
}

.ts-cart-drawer__note {
  margin: 0 0 14px;
  font-size: 0.75rem;
  color: #767676;
}

.ts-cart-drawer__checkout {
  display: block;
  width: 100%;
  padding: 15px;
  border: 0;
  border-radius: 4px;
  background: #111;
  color: #fff;
  font-size: 0.95rem;
  cursor: pointer;
}

.ts-cart-drawer__viewcart {
  display: block;
  margin-top: 12px;
  font-size: 0.82rem;
  text-align: center;
  color: inherit;
}

.ts-cart-drawer__empty {
  padding: 40px 0;
  text-align: center;
  color: #767676;
}

.ts-cart-drawer__live {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* 背面のスクロールを止める(JS が body に付ける) */
body.ts-cart-open {
  overflow: hidden;
}

変更する箇所

  • width: min(420px, 100%) — ドロワーの幅。スマートフォンでは自動的に画面幅いっぱいになります
  • #111 / #fff / #e6e6e6 — 配色。テーマのCSS変数(Dawn なら rgb(var(--color-foreground)) など)に置き換えると設定と連動します
  • transition0.32s — 開閉の速さ。0.25〜0.35秒あたりが「速いが唐突ではない」範囲です

inset-inline-end を使っているのは、将来アラビア語などのRTL言語に対応させたときに左右が自動で反転するためです。right で書いても動きますが、書き換えの手間が増えます。論理プロパティは主要ブラウザで長く実装されているので、いま新規に書くならこちらが素直です。

金額表示に font-variant-numeric: tabular-nums を入れておくと、数量を変えて桁が動いたときに数字の幅が揺れません。細かい部分ですが、再計算のたびにレイアウトがガタつくのを防げます。


JavaScript:開閉、再計算、失敗時の戻し

JavaScript:開閉、再計算、失敗時の戻し

ここが本題です。assets/ts-cart-drawer.js を新規作成し、以下をそのまま貼ります。

/* assets/ts-cart-drawer.js */
(function () {
  'use strict';

  const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])';

  class TsCartDrawer extends HTMLElement {
    connectedCallback() {
      this.overlayClickBound = false;
      this.lastFocused = null;
      this.pending = 0;

      this.addEventListener('click', this.onClick.bind(this));
      this.addEventListener('change', this.onChange.bind(this));
      this.addEventListener('keydown', this.onKeydown.bind(this));

      document.addEventListener('keydown', (e) => {
        if (e.key === 'Escape' && this.classList.contains('is-open')) this.close();
      });

      // カートに追加するフォームを横取りする
      document.addEventListener('submit', this.onAddSubmit.bind(this));

      // 他のスクリプトから開けるようにする
      document.addEventListener('ts:cart:open', () => this.open());
      document.addEventListener('ts:cart:refresh', () => this.refresh());

      // ヘッダーのカートアイコンをドロワーに繋ぐ
      document.querySelectorAll('[data-ts-cart-trigger], a[href$="/cart"]').forEach((el) => {
        el.addEventListener('click', (e) => {
          e.preventDefault();
          this.open();
        });
      });
    }

    /* ---------- 開閉 ---------- */

    open() {
      if (this.classList.contains('is-open')) return;
      this.lastFocused = document.activeElement;
      this.hidden = false;
      // hidden を外した直後だと transition が効かないので1フレーム待つ
      requestAnimationFrame(() => {
        requestAnimationFrame(() => this.classList.add('is-open'));
      });
      document.body.classList.add('ts-cart-open');
      const panel = this.querySelector('.ts-cart-drawer__panel');
      if (panel) panel.focus();
    }

    close() {
      if (!this.classList.contains('is-open')) return;
      this.classList.remove('is-open');
      document.body.classList.remove('ts-cart-open');

      const panel = this.querySelector('.ts-cart-drawer__panel');
      const done = () => {
        this.hidden = true;
        panel.removeEventListener('transitionend', done);
      };
      if (panel) {
        panel.addEventListener('transitionend', done);
        // transition が発火しない環境の保険
        setTimeout(done, 400);
      } else {
        this.hidden = true;
      }

      if (this.lastFocused && this.lastFocused.focus) this.lastFocused.focus();
    }

    /* ---------- イベント ---------- */

    onClick(e) {
      if (e.target.closest('[data-ts-close]')) {
        e.preventDefault();
        this.close();
        return;
      }

      const qtyBtn = e.target.closest('[data-ts-qty]');
      if (qtyBtn) {
        e.preventDefault();
        const line = Number(qtyBtn.dataset.tsLine);
        const input = this.querySelector(`[data-ts-qty-input][data-ts-line="${line}"]`);
        if (!input) return;
        const dir = qtyBtn.dataset.tsQty === 'up' ? 1 : -1;
        const next = Math.max(0, Number(input.value) + dir);
        this.updateLine(line, next);
        return;
      }

      const removeBtn = e.target.closest('[data-ts-remove]');
      if (removeBtn) {
        e.preventDefault();
        this.updateLine(Number(removeBtn.dataset.tsLine), 0);
      }
    }

    onChange(e) {
      const input = e.target.closest('[data-ts-qty-input]');
      if (!input) return;
      const value = Math.max(0, Math.floor(Number(input.value) || 0));
      this.updateLine(Number(input.dataset.tsLine), value);
    }

    // パネル内でフォーカスを閉じ込める
    onKeydown(e) {
      if (e.key !== 'Tab' || !this.classList.contains('is-open')) return;
      const panel = this.querySelector('.ts-cart-drawer__panel');
      const items = Array.from(panel.querySelectorAll(FOCUSABLE)).filter(
        (el) => el.offsetParent !== null
      );
      if (items.length === 0) return;
      const first = items[0];
      const last = items[items.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }

    /* ---------- カートに追加 ---------- */

    async onAddSubmit(e) {
      const form = e.target;
      if (!(form instanceof HTMLFormElement)) return;
      if (!form.action || !form.action.includes('/cart/add')) return;

      e.preventDefault();
      const submit = form.querySelector('[type="submit"]');
      if (submit) submit.setAttribute('aria-busy', 'true');

      const body = new FormData(form);
      body.append('sections', 'ts-cart-drawer-section');

      try {
        const res = await fetch(`${window.Shopify.routes.root}cart/add.js`, {
          method: 'POST',
          headers: { Accept: 'application/json' },
          body,
        });
        const data = await res.json();

        if (!res.ok) {
          this.announce(data.description || 'カートに追加できませんでした。');
          return;
        }
        await this.refresh();
        this.open();
      } catch (err) {
        this.announce('通信に失敗しました。時間をおいて試してください。');
      } finally {
        if (submit) submit.removeAttribute('aria-busy');
      }
    }

    /* ---------- 数量変更 ---------- */

    async updateLine(line, quantity) {
      const row = this.querySelector(`[data-ts-line="${line}"].ts-cart-item`);
      const input = this.querySelector(`[data-ts-qty-input][data-ts-line="${line}"]`);
      const before = input ? input.value : null;

      if (row) row.classList.add('is-loading');
      this.pending += 1;

      try {
        const res = await fetch(`${window.Shopify.routes.root}cart/change.js`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
          body: JSON.stringify({ line: line, quantity: quantity }),
        });

        if (!res.ok) {
          // 在庫上限に当たったときはここに来る
          const err = await res.json().catch(() => ({}));
          if (input && before !== null) input.value = before;
          this.announce(err.description || '数量を変更できませんでした。');
          return;
        }

        const cart = await res.json();
        this.render(cart);
        this.announce(quantity === 0 ? '商品を削除しました。' : `数量を${quantity}に変更しました。`);
      } catch (err) {
        if (input && before !== null) input.value = before;
        this.announce('通信に失敗しました。');
      } finally {
        this.pending -= 1;
        if (row) row.classList.remove('is-loading');
      }
    }

    /* ---------- 描画 ---------- */

    render(cart) {
      // 件数
      this.querySelectorAll('[data-ts-count]').forEach((el) => {
        el.textContent = cart.item_count;
      });
      document.querySelectorAll('[data-ts-cart-count]').forEach((el) => {
        el.textContent = cart.item_count;
        el.hidden = cart.item_count === 0;
      });

      // 小計
      const subtotal = this.querySelector('[data-ts-subtotal]');
      if (subtotal) subtotal.textContent = formatMoney(cart.total_price);

      // 各行の金額と数量
      cart.items.forEach((item, i) => {
        const idx = i + 1;
        const row = this.querySelector(`[data-ts-line="${idx}"].ts-cart-item`);
        if (!row) return;
        const price = row.querySelector('[data-ts-line-price]');
        if (price) price.textContent = formatMoney(item.final_line_price);
        const input = row.querySelector('[data-ts-qty-input]');
        if (input) input.value = item.quantity;
      });

      // 行数が変わった(削除された・追加された)ときはHTMLごと取り直す
      const rendered = this.querySelectorAll('.ts-cart-item').length;
      if (rendered !== cart.items.length) {
        this.refresh();
        return;
      }

      this.updateProgress(cart.total_price);
    }

    async refresh() {
      try {
        const res = await fetch(`${window.Shopify.routes.root}?sections=ts-cart-drawer-section`);
        const json = await res.json();
        const html = json['ts-cart-drawer-section'];
        if (!html) return;

        const doc = new DOMParser().parseFromString(html, 'text/html');
        const fresh = doc.querySelector('ts-cart-drawer');
        if (!fresh) return;

        this.innerHTML = fresh.innerHTML;

        const cartRes = await fetch(`${window.Shopify.routes.root}cart.js`);
        const cart = await cartRes.json();
        this.updateProgress(cart.total_price);

        document.querySelectorAll('[data-ts-cart-count]').forEach((el) => {
          el.textContent = cart.item_count;
          el.hidden = cart.item_count === 0;
        });
      } catch (err) {
        // 取り直せなければカートページに任せる
        console.warn('[ts-cart-drawer] refresh failed', err);
      }
    }

    /* ---------- 送料無料まであといくら ---------- */

    updateProgress(totalPrice) {
      const bar = this.querySelector('[data-ts-progress]');
      if (!bar) return;

      const threshold = Number(this.dataset.threshold) || 0;
      if (threshold <= 0) return;

      const remain = Math.max(0, threshold - totalPrice);
      const ratio = Math.min(1, totalPrice / threshold);

      bar.style.setProperty('--ts-progress', String(ratio));
      bar.dataset.reached = remain === 0 ? 'true' : 'false';

      const text = this.querySelector('[data-ts-progress-text]');
      if (!text) return;
      text.textContent =
        remain === 0
          ? '送料無料の対象です。'
          : `あと ${formatMoney(remain)} で送料無料になります。`;
    }

    announce(message) {
      const live = this.querySelector('[data-ts-live]');
      if (live) live.textContent = message;
    }
  }

  /* ---------- 金額の整形 ---------- */
  // Shopify の Cart API は最小通貨単位の整数を返す(日本円なら 1000 = 1,000円)
  function formatMoney(cents) {
    const format = (window.Shopify && window.Shopify.currency) || {};
    const code = format.active || 'JPY';
    const zeroDecimal = ['JPY', 'KRW', 'VND', 'CLP', 'ISK'];
    const digits = zeroDecimal.includes(code) ? 0 : 2;
    const value = digits === 0 ? cents : cents / 100;

    return new Intl.NumberFormat(document.documentElement.lang || 'ja-JP', {
      style: 'currency',
      currency: code,
      minimumFractionDigits: digits,
      maximumFractionDigits: digits,
    }).format(value);
  }

  if (!customElements.get('ts-cart-drawer')) {
    customElements.define('ts-cart-drawer', TsCartDrawer);
  }
})();

変更する箇所

  • a[href$="/cart"] — ヘッダーのカートリンクを拾うセレクタです。テーマによってリンクの形が違うので、実際のマークアップを見て [data-ts-cart-trigger] を直接付けるほうが確実です
  • zeroDecimal の配列 — 扱う通貨に応じて増減させます。日本円だけなら ['JPY'] で足ります
  • document.querySelectorAll('[data-ts-cart-count]') — ヘッダーのカート個数バッジに data-ts-cart-count を付けておくと、ドロワー内の変更が自動で反映されます

押さえておきたい実装のポイント

hidden を外した直後は transition が効かない

display: none から表示に切り替えた同じフレームで transform を変えても、ブラウザは「最初からその位置にあった」と解釈してアニメーションしません。requestAnimationFrame を2重にしているのはそのためです。1回では足りない環境があるので、確実を取って2フレーム待っています。

金額の整形を Liquid の | money に頼らない

| money はサーバー側でしか動きません。JavaScript で再計算した値を出すには、自前で整形するか、refresh() でHTMLごと取り直すかの二択になります。この実装は、数量だけ変わったときは formatMoney() で軽く書き換え、行数が変わったときだけ refresh() でHTMLを取り直す形にしています。取り直しは通信が1往復増えるので、必要なときだけに絞ります。

在庫上限を超えたときは422が返る

/cart/change.js は在庫が足りないと HTTP 422 とエラーメッセージのJSONを返します。上のコードはこれを検知して入力欄を元の値に戻し、aria-live 領域にメッセージを出します。ここを書かないと、画面上は「5」に増えたのにカートは「3」のまま、という食い違いが起きます。

数量変更のときの通信の流れ
行をロック該当行を半透明にして二重送信を防ぐ
change.jsline と quantity を送る
結果で分岐成功なら再描画、422なら元の値に戻す
行数が変われば取り直しSection Rendering でHTMLごと差し替え

数量を1増やすだけなら通信は1往復で済み、削除して行が消えるときだけHTMLを取り直します。ここを分けないと、を連打するたびにHTML全体を取り直すことになり、体感がもたつきます。


Section Rendering API でHTMLを取り直す

Section Rendering API でHTMLを取り直す

refresh()?sections=ts-cart-drawer-section を叩いています。これは Section Rendering API で、指定したセクションのHTMLだけをJSONで返してもらう仕組みです。使うにはセクションファイルが必要なので、sections/ts-cart-drawer-section.liquid を新規作成します。

{%- comment -%} sections/ts-cart-drawer-section.liquid {%- endcomment -%}
{%- render 'ts-cart-drawer' -%}

{% schema %}
{
  "name": "Cart drawer (ts)",
  "settings": []
}
{% endschema %}

セクション名は ?sections= に渡す文字列とファイル名が対応します。ts-cart-drawer-section.liquid なら ?sections=ts-cart-drawer-section です。ここを間違えると refresh() が黙って何も返さないので、うまく動かないときは真っ先に確認してください。ブラウザのアドレスバーに https://ストア名.myshopify.com/?sections=ts-cart-drawer-section と入れて、JSONが返るかを直接見るのが早いです。

このセクションはテーマエディタの「セクションを追加」に出てほしくないので、presets は書きません。presets がないセクションは追加候補に出ません。

layout/theme.liquid に置いた {%- render 'ts-cart-drawer' -%} を、セクション経由に切り替えてもかまいません。その場合は {% section 'ts-cart-drawer-section' %} に置き換えます。どちらでも動きますが、セクション経由にしておくとテーマ設定を持たせやすくなります。

取り直しの範囲を絞るという選択肢

?sections= にはカンマ区切りで複数のセクション名を渡せます。カートドロワーと同時にヘッダーのカート個数バッジも更新したい場合、?sections=ts-cart-drawer-section,header のように書けば、1往復で両方のHTMLが返ります。返ってきたJSONはセクション名をキーにしたオブジェクトなので、それぞれ必要な要素だけを差し替えます。

ただし、返るHTMLはセクション全体です。ヘッダーのように中身の重いセクションを毎回取り直すと、数量を1つ変えるたびにナビゲーション全体のHTMLが飛んでくることになります。バッジの数字だけなら cart.js のレスポンスから item_count を読んで書き換えるほうが軽く、この記事の実装もそちらを採っています。取り直すセクションを増やすかどうかは、そのセクションのHTMLの重さと、書き換えたい箇所の数で判断してください。


送料無料まであといくらのバー

送料無料まであといくらのバー

snippets/ts-cart-progress.liquid を新規作成します。

{%- comment -%} snippets/ts-cart-progress.liquid {%- endcomment -%}
{%- assign threshold = settings.ts_free_shipping_threshold | default: 10000 -%}
{%- if threshold > 0 -%}
  {%- assign remain = threshold | minus: cart.total_price -%}
  {%- if remain < 0 -%}{%- assign remain = 0 -%}{%- endif -%}

  <div
    class="ts-cart-progress"
    data-ts-progress
    data-reached="{% if remain == 0 %}true{% else %}false{% endif %}"
    style="--ts-progress: {{ cart.total_price | times: 1.0 | divided_by: threshold | at_most: 1 }};"
  >
    <p class="ts-cart-progress__text" data-ts-progress-text>
      {%- if remain == 0 -%}
        送料無料の対象です。
      {%- else -%}
        あと {{ remain | money }} で送料無料になります。
      {%- endif -%}
    </p>
    <div class="ts-cart-progress__track" aria-hidden="true">
      <span class="ts-cart-progress__fill"></span>
    </div>
  </div>
{%- endif -%}

CSSを assets/ts-cart-drawer.css の末尾に足します。

/* --- 送料無料バー --- */

.ts-cart-progress {
  margin-bottom: 24px;
  padding: 14px 16px;
  border-radius: 6px;
  background: #f6f6f4;
}

.ts-cart-progress__text {
  margin: 0 0 10px;
  font-size: 0.8rem;
  line-height: 1.5;
}

.ts-cart-progress__track {
  height: 4px;
  border-radius: 999px;
  background: #dededa;
  overflow: hidden;
}

.ts-cart-progress__fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: #111;
  transform: scaleX(var(--ts-progress, 0));
  transform-origin: left center;
  transition: transform 0.4s cubic-bezier(0.32, 0.72, 0, 1);
}

.ts-cart-progress[data-reached='true'] .ts-cart-progress__fill {
  background: #2f7d54;
}

変更する箇所

  • 10000 — 送料無料のしきい値。テーマ設定から変えられるようにするなら次項を参照
  • #2f7d54 — 達成時の色。ブランドカラーに合わせます
  • 文言 — 「送料無料」の条件が地域限定なら「本州送料無料」のように正確に書きます

バーの伸縮に width ではなく transform: scaleX() を使っているのは、レイアウトの再計算を挟まずに済むためです。数量変更のたびに走るアニメーションなので、ここは軽いほうを選びます。

なお、aria-hidden="true" を付けているのはバーのトラック部分だけで、金額の文言は読み上げの対象に残しています。プログレスバーはグラフィックとしての補助表現で、情報の本体は「あと◯◯円」というテキストのほうにあるためです。バー側に role="progressbar"aria-valuenow を付ける書き方もありますが、同じ情報を二重に読み上げることになるので、この構成では省いています。

しきい値をテーマ設定から変えられるようにする

config/settings_schema.json に以下のブロックを追加します。配列の要素なので、既存の要素との間にカンマを忘れないでください。

{
  "name": "カートドロワー",
  "settings": [
    {
      "type": "number",
      "id": "ts_free_shipping_threshold",
      "label": "送料無料になる金額",
      "info": "税抜・最小通貨単位で入力します(日本円なら 10000 = 10,000円)。0 でバーを非表示。",
      "default": 10000
    }
  ]
}

これでテーマエディタの テーマ設定 → カートドロワー から金額を変更できます。クライアントに引き渡す案件では、ここをハードコードで残さないほうが後の問い合わせが減ります。

info に単位を明記しているのは、日本円以外の通貨だと「10000」が「100.00」を意味するためです。海外向けストアで運用する場合、この注意書きがないとほぼ間違えます。

しきい値の判定に cart.total_price を使っている点にも注意が必要です。これは割引適用後の金額なので、クーポンで小計が下がるとバーの達成状態が戻ることがあります。割引前で判定したいなら cart.original_total_price、送料無料の条件を商品代金だけに限定したいなら、ギフトカードなど対象外の商品を除いた合計を Liquid 側で組み立てる必要があります。どの金額を基準にするかは、管理画面の配送設定で組んだ条件と揃えてください。

Shopify のカスタマイズには構造的にできることとできないことがあり、事前に線引きを把握しておくと見積もりの精度が上がります。


Dawn 標準のドロワーと共存させる

Dawn 標準のドロワーと共存させる

Dawn には cart-drawer.jscart-drawer.liquid が最初から入っています。この記事の実装をそのまま足すと、両方が「カートに追加」フォームを横取りして二重にリクエストが飛ぶ可能性があります。

Dawn標準を使うか、自作に置き換えるかの判断

自作に置き換える

  • 送料無料バーなど独自要素を足す
  • デザインの自由度が要る
  • テーマ更新時に自分で追随する

Dawn標準を拡張する

  • 見た目の調整だけで足りる
  • テーマ更新に自動で乗る
  • 拡張点が限られる

送料無料バーや独自のレコメンド枠を入れるなら自作に寄せたほうが早く、色とサイズを整えるだけなら標準を CSS で上書きするほうが保守が楽です。

自作に置き換える場合、Dawn 側を止める手順は次のとおりです。

1. テーマ設定でカートの種類を「ページ」に変更する

テーマエディタの テーマ設定 → カート → カートの種類 で「ページ」を選びます。これで Dawn の cart-drawer.liquid はレンダリングされなくなります。カートアイコンは通常のリンクになるので、この記事の JavaScript が横取りしてドロワーを開きます。

2. 商品フォームの二重送信を確認する

Dawn の product-form.js<product-form> カスタム要素の中で submit を捕まえています。この記事のコードは document レベルで submit を拾うので、Dawn 側が preventDefault() した後でも発火します。Dawn の商品フォームをそのまま使う場合は、onAddSubmit の冒頭に次の1行を足して、Dawn が処理するフォームを除外してください。

      // Dawn の product-form に任せるフォームは処理しない
      if (form.closest('product-form')) return;

そのうえで Dawn 側の追加完了を拾ってドロワーを開くなら、assets/product-form.js の追加成功後の処理に以下を足します。

      document.dispatchEvent(new CustomEvent('ts:cart:refresh'));
      document.dispatchEvent(new CustomEvent('ts:cart:open'));

3. クイックビューやアプリ由来のフォームを確認する

コレクションページのクイックビュー、アプリが埋め込む定期購入ウィジェット、サードパーティのバンドル機能なども /cart/add へ投げます。これらは <product-form> の外にあることが多いので、上の除外条件では拾えません。実装後にコレクションページとアプリの動線を一度ずつ触り、ネットワークタブで cart/add.js が1回だけ飛んでいるかを確認してください。二重に飛ぶなら、そのフォームにも除外用の目印を付けて onAddSubmit で弾きます。

どちらの経路を選ぶかは、Dawn のバージョンとカスタマイズ量で決まります。テーマを大きく作り替える案件なら自作に寄せ、標準構成に近いなら Dawn のイベントに相乗りするほうが後の更新で楽になります。


動作確認の手順

動作確認の手順

コードを貼ったら、以下を順に確認します。開発ストアではなく本番テーマにいきなり入れず、テーマを複製してから作業してください。

確認項目 見るところ
開閉 カートアイコンをクリック→右から出る。オーバーレイ・×・Escで閉じる
背面スクロール 開いている間、後ろのページが動かない。閉じたら元の位置に戻る
数量の増減 +−で金額と小計が変わる。ネットワークタブに change.js が1回だけ飛ぶ
削除 行が消え、件数と小計が合う。最後の1件を消したら空表示になる
送料無料バー 数量変更でバーの長さと文言が両方変わる
在庫上限 在庫より多い数を入力→エラーが出て入力欄が元に戻る
キーボード Tabでパネル内を巡回し、外に出ない。閉じるとフォーカスが元に戻る
モバイル iOS Safari でパネル内をスクロールしても背面が動かない

「在庫上限」の確認は、テスト用商品の在庫を1に設定して「在庫切れの場合でも販売を続ける」をオフにすると再現できます。この確認を飛ばすと、本番で在庫の少ない商品が売れ始めたときに気づきます。

ネットワークタブで change.js が複数回飛んでいたら、イベントリスナーが多重登録されています。refresh()innerHTML を差し替えたあとにリスナーを付け直す実装だと起きやすい症状ですが、この記事のコードは要素本体に1回だけ登録してイベント委譲で処理しているため、差し替え後も再登録は不要です。

スクリーンリーダーでの確認も、可能なら1度は通してください。macOS の VoiceOver(Command + F5)でドロワーを開き、数量を変えたときに「数量を2に変更しました」という読み上げが入るかを見ます。aria-live="polite" は視覚的には何も起きないため、目視だけのテストでは壊れていても気づけない箇所です。


うまくいかないとき

うまくいかないとき

ドロワーが出ない・一瞬で消える

hidden 属性と is-open クラスの両方を確認します。開発者ツールで要素を選び、hidden が外れているのに is-open が付いていなければ requestAnimationFrame の部分が動いていません。JavaScript がコンソールでエラーを出していないか確認してください。ts-cart-drawer.js が404になっているケースもよくあります。{{ 'ts-cart-drawer.js' | asset_url }} のファイル名と、assets/ に置いた実ファイル名が一致しているかを見ます。

アニメーションせずに一瞬で出る

display: none からの復帰でよく起きます。requestAnimationFrame の2重ネストを削っていないか確認してください。また、OSの「視差効果を減らす」設定がオンだと prefers-reduced-motion が効いて意図的に即座に表示されます。これは仕様どおりの挙動です。

金額が100倍・100分の1になる

formatMoney()zeroDecimal 判定です。Cart API は最小通貨単位の整数を返すので、日本円の 1000 は「1,000円」ですが、米ドルの 1000 は「$10.00」です。window.Shopify.currency.active が期待どおりの値を返しているか、コンソールで確認します。多通貨(Shopify Markets)を有効にしているストアでは、表示通貨と active が食い違うことがあるので、その場合は refresh() でHTMLごと取り直して Liquid の | money に任せるほうが安全です。

refresh() が何も返さない

Section Rendering API のセクション名の不一致がほとんどです。ブラウザで https://ストア名.myshopify.com/?sections=ts-cart-drawer-section を直接開き、JSONが返るか確認します。404やHTMLが返るなら、sections/ 内のファイル名を見直してください。パスワード保護中のストアでは、ログインしたブラウザでないと弾かれます。

iOS Safari で背面がスクロールしてしまう

body { overflow: hidden } だけでは止まらないことがあります。パネル側に overscroll-behavior: contain を入れているのはそのためですが、それでも動く場合は開いた時点のスクロール位置を保存して position: fixed を当てる方法に切り替えます。

    // open() の中で
    this.scrollY = window.scrollY;
    document.body.style.position = 'fixed';
    document.body.style.top = `-${this.scrollY}px`;
    document.body.style.width = '100%';

    // close() の中で
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.width = '';
    window.scrollTo(0, this.scrollY);

この方法は確実に止まりますが、閉じたときにスクロール位置を復元する処理を忘れると、画面が最上部に飛びます。上のコードは復元まで含めてあります。

数量を連打すると表示がずれる

リクエストが順不同で返ってきたときに起きます。この記事のコードは行に is-loading を付けて pointer-events: none にしているため、処理中の連打は物理的にブロックされます。それでもキーボード入力から届く場合があるので、updateLine() の冒頭に if (this.pending > 0) return; を足すと完全に直列化できます。応答性は落ちるので、通信が遅い環境向けの選択肢として持っておく程度でかまいません。

テーマエディタでプレビューすると二重に出る

layout/theme.liquid{%- render 'ts-cart-drawer' -%} と、セクションとしての読み込みが両方生きている状態です。どちらか一方に絞ります。テーマエディタ上では Dawn 標準のドロワーが同時に描画されていることもあるので、要素の数を開発者ツールで確認してください。

商品を追加してもドロワーが開かない

onAddSubmit が発火していないか、form.action の判定で弾かれています。まず開発者ツールの Sources でブレークポイントを置き、submit が届いているかを見ます。届いているのに素通りしているなら form.action.includes('/cart/add') の判定です。テーマによっては action を空にして JavaScript 側で送信先を決めているものがあり、その場合 form.action はページ自身のURLになるので一致しません。フォームに data-ts-add-form のような目印を付け、その属性でも拾うように条件を足してください。

削除したのに行が残る

render() の行数チェックが refresh() を呼び、その refresh() が失敗しています。コンソールに [ts-cart-drawer] refresh failed が出ていないか確認してください。出ていれば Section Rendering API 側の問題なので、上の「refresh() が何も返さない」を見ます。出ていないのに残る場合は、data-ts-line の番号と実際の行の並びがずれています。削除後は行番号が繰り上がるため、HTMLを取り直さずに番号だけ使い回すとこの症状になります。行数が変わったら必ず取り直す、という分岐を消していないか確認してください。

同じ「ライブラリを足さずに素のJavaScriptで組む」考え方は、スライダーの実装にもそのまま使えます。


よくある質問

よくある質問

カートドロワーにするとSEOに影響はありますか

カートページ自体は検索対象になる性質のページではないため、ドロワー化による検索順位への直接の影響は想定しにくいところです。ただし /cart のリンクを JavaScript で潰してしまうと、JavaScript が動かない環境でカートに到達できなくなります。この記事の実装は <a href="/cart"> のまま残して preventDefault() しているので、この問題は起きません。Shopify のURL構造そのものの制約についてはShopifyのSEO対策|URL構造の制約とその回避策【2026年】でまとめています。

アプリを使わずに送料無料バーを出して問題ないですか

しきい値の判定をフロントエンドで行っているだけなので、実際の送料設定とは独立しています。表示上「送料無料」と出ているのに Shopify 側の送料設定が対応していなければ、購入手続き画面で送料が加算されてしまいます。管理画面の 設定 → 配送と配達 で、同じ金額の送料無料条件を必ず設定してください。ここが噛み合っていないと、購入直前で金額が変わったという問い合わせにつながります。

Dawn 以外のテーマでも動きますか

Online Store 2.0 対応テーマであれば、Section Rendering API と Cart AJAX API は共通で使えるため基本は動きます。ただし、テーマ独自のカート制御スクリプトが同じイベントを掴んでいると衝突します。組み込む前に、そのテーマの assets/cart を含むJSファイルがあるかを確認し、あれば競合を先に潰してください。テーマ選定の段階から迷っているならShopifyテーマの選び方|無料・有料・オリジナルの判断基準【2026年】が参考になります。

数量変更のたびに通信が発生するのは重くないですか

/cart/change.js のレスポンスはカートのJSONだけなので、ページ全体を読み込み直すより軽く済みます。この記事の実装では、行数が変わらない場合はHTMLを取り直さず数値だけ書き換えるため、通信は1往復です。体感が重いと感じるなら、is-loading の半透明表示を入れることで「処理中」であることが伝わり、待たされている印象を減らせます。

カート内で商品をおすすめする枠は追加できますか

/recommendations/products.json エンドポイントを使えば実装できます。この記事の refresh() と同じ要領で、カート内の商品IDを渡してレコメンドを取得し、ドロワー下部に差し込む形になります。ただしドロワーが長くなるほど「購入手続きへ」ボタンが遠のくので、フッターを固定したまま本文だけスクロールさせる構造(この記事のCSSはそうなっています)を崩さないようにしてください。

カートに商品プロパティ(名入れなど)が入っている場合も動きますか

動きます。line パラメータは行番号を見ているだけなので、同じバリアントがプロパティ違いで複数行に分かれていても、狙った行だけを変更できます。むしろ id にバリアントIDを渡す書き方だと、プロパティ違いの複数行がまとめて動いてしまうことがあります。この記事が forloop.index を使っている理由がここです。プロパティの中身をドロワーに表示したい場合は、item.properties をループして出力する処理をマークアップに足してください。先頭がアンダースコアのプロパティは非表示扱いなので、除外する分岐も一緒に書きます。


まとめ

カートドロワーは「横から出る」だけなら小さな実装ですが、実務で効くのはその周辺です。

  • 再計算の分岐 — 数量だけ変わったときは数値を書き換え、行数が変わったときだけHTMLを取り直す
  • 失敗したときの戻し — 422で在庫上限に当たったら入力欄を元の値に戻し、メッセージを出す
  • 背面スクロールの制御overflow: hidden で足りなければ position: fixed に切り替える
  • 送料無料バーは配送設定と揃える — フロントの表示だけ先に作ると購入手続き画面で食い違う
  • 追加フォームの経路を数える — 商品ページ・クイックビュー・アプリ由来のフォームすべてで cart/add.js が1回だけ飛ぶことを確認する

この記事のコードは外部ライブラリを一切使っていません。ドロワーのためだけにJSライブラリを1本追加すると、全ページで読み込みが増えます。カスタム要素と fetch だけで組める範囲なら、素で書いたほうが速く、壊れたときに追いやすくなります。

引き渡し後の運用まで考えるなら、しきい値をテーマ設定に逃がしておくこと、文言をロケールファイルに置くこと、この2点だけは最初にやっておく価値があります。どちらも後から直すとテーマ全体を触り直すことになり、公開後の小さな修正依頼が毎回コード改修になります。

構築の全体像や費用感を先に押さえておきたい場合は、こちらもあわせて確認してください。