GODRICH

決済画面 デザイン 9選 — カートから支払いまでコピペOK

決済画面 デザインとは、買い物かごに入れた人を「これが欲しい」から支払いボタンの手前まで案内する一連の画面のことです。数量を変えると合計が追いかける行、クーポンで割り込む割引行、選んだ支払い方法の下に開く詳細枠。九つともHTML・SCSS・数行のJavaScriptだけで組んであり、貼ったその場で動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は難易度ではなく、注文が完成するまでの道順です。かごの中で数量と小計が一緒に染まる行(01)から始まり、「あとで買う」へ行が滑り降り(02)、クーポンが内訳に割り込み(03)、在庫上限が数量を弾き戻し(04)、支払い方法のカードが開き(05)、配送先が畳まれ(06)、到着日が入れ替わり(07)、同意項目が順に点り(08)、最後に下部の決済バーが内訳に席を譲って退きます(09)。九つとも同じ色・間隔・イージング値を分け合っているので、いくつかを一枚の決済画面に混ぜてもトーンは崩れません。

01数量を変えると小計と合計が一緒に染まる行

数量の値を変えると、その行の小計と下の合計が同じ瞬間に計算し直され、変わった数字のセル二つだけがbackground-colorで染まってtransitionで冷めていきます。数字はfont-variant-numeric: tabular-numsで桁が増えても枠が揺れないように置き、描画機が測った累積変化面積は2.699%でした。ショッピングカートの明細行や、予約人数を変える画面に使います。

background-colortransitiontabular-nums
// 数字のセル — tabular-nums + min-width で桁が増えても幅が揺れない
.qs__sum {
  min-width: 58px;
  padding: $sp-1;
  border-radius: $r-xs;
  text-align: right;
  font-variant-numeric: tabular-nums;
  // 実クリックの経路 — 一度塗って(transition: none)、クラスを外すとこのtransitionが冷ます
  background-color: rgba($stage-yellow, 0);
  transition: background-color $dur-enter $ease-out;
  &.is-hot {
    background-color: rgba($stage-yellow, .62);
    transition: none;
  }
}

02あとで買うへ滑り降りる明細行

「あとで買う」を押すとその行が高さを失って畳まれ、下の保管グループに同じ商品が上から降りてきて、二つの件数が一緒に変わります。行ひとつがgrid-template-rows: 1fr → 0frへ畳まるのと同時に、相手の行が0fr → 1frへ開くのでカード全体の高さは動かず、描画機は累積面積11.938%・強度104.9を記録しました。「あとで買う」リストや、ほしい物リストへの移動に使います。

grid-template-rows0frtranslateY
// 行ひとつ = 高さをやり取りする枠。実クリックの経路はtransitionで滑らかに畳む
.mv__row {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows $dur-base $easing;
}
.mv__row.is-out { grid-template-rows: 0fr; }
// 畳まる枠にパディングを置かない — 0fr になっても縦パディングは残る。
// 内側を包むこの枠が高さ0になって、パディングを持つ本体の行を切り取る
.mv__box {
  min-height: 0;
  overflow: hidden;
}

03内訳の間に割り込む割引行

クーポンコードを入れて適用すると、商品金額と合計の間に割引行が高さ0から開いて割り込み、下の行がその分だけ押し下がります。結果の一文はaria-live領域が読み上げ、合計の数字も同じ計算で入れ替わり、描画機は累積面積15.799%・強度110.8を記録しました。クーポン・プロモコード入力欄のある決済の内訳に使います。

grid-template-rowsaria-liveopacity
.cp__disc {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $dur-base $easing;
}
.cp.is-on .cp__disc { grid-template-rows: 1fr; }
function cpApply() {
  cp.classList.remove('is-demo');
  var code = cpCode.value.trim().toUpperCase();
  var on = code === cpValidCode;
  cp.classList.toggle('is-on', on);
  cpTotalValue.textContent = cpMoney(cpAmount(cpItems.textContent) - (on ? cpAmount(cpOff.textContent) : 0));
  cpMsg.textContent = on ? cpOkText : (code ? cpWrongText : cpEmptyText);
}

04在庫を超えると弾き返される数量

残り在庫より大きい数を入れると、数字が上限へ一度弾き返され、行の上に警告の帯が降りて残りの個数を伝えます。跳ね返りはcubic-bezierの行き過ぎるイージングによるtranslateYで、帯はrole="alert"なので画面を見られない人にも同じ内容が届き、帯が降りて立つ24pxの場所を行の上に確保してあるので商品名は隠れません。描画機が累積面積5.05%・強度123.9を記録したこの部品は、在庫限定の商品や一人あたりの購入数に上限のある特価に向きます。

translateYrole="alert"cubic-bezier
// 跳ね返り — 上限へ戻るとき、自分の位置を一度通り過ぎる(行き過ぎイージング)
@keyframes cs-bounce {
  0%, 25% { transform: translateY(0) scale(1); }
  29% { transform: translateY(-10px) scale(1.16); }
  40%, 100% { transform: translateY(0) scale(1); }
}

05選んだ支払い方法だけが下に開くカード

支払い方法のカード三枚のうち一つを選ぶと、そのカードの詳細欄だけが高さ0から開き、先に開いていた欄は同じ時間で閉じます。開け閉めの力はラジオボタンの:checkedが持ち、キーボードの矢印で移動してもaria-expandedの値がリアルタイムで追いかけ、詳細欄は文書の流れの中でgrid-template-rowsにより開くので、絶対配置のリストのように狭い画面からはみ出しません。描画機は23コマ中8コマに動きがあり、累積面積17.7%でした。カード・簡単決済・口座振込を選ぶ決済画面に使います。

grid-template-rowsaria-expanded:checked
// 詳細欄 — 閉じ0fr、開き1fr。pxで見積もらないので文字が長くなっても合う
.pm__fold {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.pm__item:has(.pm__radio:checked) .pm__fold { grid-template-rows: 1fr; }
.pm__detail {
  min-height: 0;
  overflow: hidden;
}

06注文者と同じで畳まれて埋まる配送先

「注文者と同じ」を入れると配送先の入力欄がまとめて畳まれ、一行の要約になって、その行に注文者の氏名と住所がすでに入っています。値の複写はJavaScriptが行い、畳まれた欄はaria-hidden="true"inertでフォーカスの順番から外れるのでTabキーが空欄をさまよいません。描画機は累積面積22.416%・強度55.7を記録しました。注文者と配送先を別に受け取る注文フォームや、請求先住所の入力に使います。

grid-template-rowsaria-hiddeninert
function syncShipping() {
  sa.classList.remove('is-demo');                 // 人が触るとプレビューのループは終わる
  var folded = same.checked;
  fold.setAttribute('aria-hidden', folded ? 'true' : 'false');
  fold.toggleAttribute('inert', folded);          // 畳まれた欄にはTabでも入らない
  if (folded) {
    document.getElementById('saName').value = whoName.textContent.trim();
    document.getElementById('saAddr').value = whoAddr.textContent.trim();
    sum.textContent = whoName.textContent.trim() + ' · ' + whoAddr.textContent.trim();
  }
}

07配送方法を変えると日付が滑って変わる到着チップ

通常・お急ぎ・早朝のどれかを選ぶと、到着予定チップの中の日付が上へ押し出され、新しい日付が下から上がって収まります。チップはoverflow: hiddenの窓の中をtranslateYで押すオドメーターなので、二つの日付が重なって読める中間コマはなく、変わった結果はaria-liveの枠が一文で読み上げます。送料も選んだ方法に合わせて変わります。描画機は23コマ中12コマに動きがあり、累積面積は9.291%でした。配送方法ごとの到着予定日の案内や、受け取り時間の選択に使います。

overflowtranslateYaria-live
// 窓は一行だけ見せる — 旧の行は上へ押し出され、次の行が下から上がる
.sp__win {
  height: 16px;
  overflow: hidden;
}
.sp__roll {
  display: block;
  transform: translateY(calc(var(--i, 0) * -33.3333%));
  transition: transform $dur-base $easing;
}

08すべて同意が下を順に点ける規約のまとまり

「すべて同意」を入れると、下の四項目のチェックが順に描かれ、そのうち一つを外すと「すべて同意」だけが静かに外れます。チェックはSVGのpolylinestroke-dashoffset: 1 → 0で描き、順番のずらしはtransition-delay: calc(var(--i) * 60ms)の一行で与え、「いくつか点いている」という途中の状態はチェックボックスのindeterminateがそのまま持ちます。描画機は累積面積1.8%・強度122.3・23コマ中19コマに動きを記録しました。決済前の規約同意のまとまりや、会員登録の必須・任意同意に使います。

stroke-dashoffsetanimation-delayindeterminate
<label class="ta__mark" for="ta-1" aria-hidden="true">
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <polyline class="ta__tick" points="5 12.5 10 17 19 7" pathLength="1"
      fill="none" stroke="currentColor" stroke-width="3"
      stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
</label>
.ta__tick {
  stroke-dashoffset: 1;
  transition: stroke-dashoffset $dur-base $easing;
  transition-delay: calc(var(--i, 0) * 60ms);
}
.ta__box:checked ~ .ta__mark .ta__tick { stroke-dashoffset: 0; }

09内訳が見えると下がって収まる下部の決済バー

画面の下に貼り付いていた合計・決済バーは、注文内訳が視界に入った瞬間に下へ降り、内訳が外れると元の位置へ戻ります。見えているかどうかはスクロール座標の計算ではなくIntersectionObserverが判定し、バーはスクロール枠の外にposition: absoluteで重ねてあるので、スクロール位置0の最初の場面でも立っています。内訳カードがバーの降りた席を埋めるので、空いた画面が代表画像に出ることはありません。描画機は累積面積25.205%・強度85.2・23コマ中16コマに動きを記録しました。モバイル決済画面の固定ボタンや、長い注文フォームの合計バーに使います。

IntersectionObservertranslateYposition
if (window.IntersectionObserver) {
  var watcher = new IntersectionObserver(function (entries) {
    for (var i = 0; i < entries.length; i++) {
      co.classList.toggle('is-down', entries[i].isIntersecting);
    }
  }, { root: view, threshold: 0.45 });
  watcher.observe(summary);
}

どこで壊れるか — 落とし穴

最初に引っかかったのは0frそのものでした。02・03・05・06の四項目がgrid-template-rows: 0fr ↔ 1frで枠を畳む方式を使うのですが、最初は畳む枠そのものに縦パディングを与えていたため、枠が完全に閉じず8pxの薄い帯が残りました。0frはその枠の内容の高さを0にするだけで、パディングはそのまま残すからです。直し方は構造を二つに分けることです。外側の枠はgrid-template-rowsmin-height: 0だけを持ち、パディングは内側の行に置きます。外側が高さ0になるとパディングを持つ本体の行を切り取るので、枠は音もなく完全に閉じます。

二番目は、代表画像が消えていく側を見せる問題でした。ポスターは前のコマと最も大きく変わった場面を選ぶのですが、退場は登場より変化量が大きいことが多く、04の警告帯が引っ込む場面が勝ち続けました。引っ込みを四コマに分けてゆっくりにし、降りてくる瞬間を一コマのカットに置くことで、帯が立っている場面が再び勝つようになり、ループの復帰は2秒の89%以内に収めました。02と03も同じ理由で戻りを70〜86%にゆっくり広げ、09は内訳カードがバーの降りた席を埋める設計に変えました。そのままでは「バーの消えた空き画面」が代表画像に出ます。

三番目は、重ねた数字が半透明の幽霊になることでした。01はプレビューのループで数量2→3を見せるため二つの数字を同じ枠に重ねて置いたのですが、最初はopacityでクロスフェードさせ、二つの数字が重なって読めるコマがそのままポスターになりました。数字と文字の入れ替えはすべてsteps(1, end)のカットで切り替え、カットだけでは動いたコマ数が足りず測定が満たないため、枠が少し持ち上がって戻るtranslateYを添えました。05も切り替えをカットにして、開いた文章が一度上がることでコマを埋めています。

四番目は、プレビューのループを外して初めて現れました。03のプレビューでは入力欄の上にコードの文字SAVE3000を重ねるのですが、これをdisplay: noneで隠すと、静的な経路で入力欄の形が揺れます。color: transparentで色だけ消して場所を残し、04の「入れてみた値」も同じ方法です。06は別の種類の事故を先に避けました。要約の行を縦に上げるとoverflow: hiddenの枠に半分切り取られた場面がポスターに選ばれるので、横から入るようにしたのです。

最後は舞台の色ではなく舞台の外でした。04の警告帯は絶対配置で商品の行の上に降りてくるのですが、最初は行を覆ってしまい、代表画像から商品名が消えました。上側24pxを帯の立つ場所として確保し、背景を行に移して直しました。09のバーもposition: absoluteでスクロール枠に重なります。スクロール位置0でも立っていなければならないのでstickyは使えません。プレビューのスクロール0の状態ではstickyは一切効かず、24コマのどこにも現れないからです。九つのソースはここに書いた値のままzipに入っていて、開くパスワードはbe3rd37nです。

アクセシビリティ

動きを減らしてほしいという設定(prefers-reduced-motion: reduce)で九つが何を消し何を残すかは、項目ごとに違います。共通の原則は一つです。自動で回る動きは止めて、買い物客が操作した結果(数字・状態・文章)は残します。01は数字の入れ替えと染まりを止めて今の値を見せ、02は畳みを止めて二つのリストの件数を見せ、03は割引行が開いた状態で止め、04は跳ね返りを止めて帯・赤い枠・上限の数字を残し、05は一枚のカードが選ばれて開いた状態を見せ、06は畳まれた配送先と要約の一行を見せ、07は回転を止めて今の到着日を見せ、08は描画を止めて四項目が点いた状態を見せ、09はバーが降りた状態で止めます。このreduceブロックの宣言にはすべて!importantが付いています。プレビューのループ側の規則がクラスを重ねた形なので、詳細度で勝ってしまうからです。基準はMDNのprefers-reduced-motionの説明に倣い、09が使うIntersectionObserverは、MDNのIntersection Observer API文書にまとめられています。

画面を見られない人に結果を伝える仕組みも項目ごとに違います。

項目 実際に付いているもの 何を伝えるか
01 数量の同期 数量ボタンのaria-label どの商品の数量を増やすか・減らすか
02 あとで買う 移動ボタンのaria-label どの商品をどのリストへ移すか
03 クーポン割引行 aria-live="polite" 適用されたか・存在しないコードか
04 在庫上限 role="alert" 残り数量と戻された上限値
05 支払い方法 aria-expanded・ラジオグループ どの方法が開いているか
06 注文者と同じ aria-hiddeninert 畳まれた欄がフォーカス順から外れること
07 到着チップ aria-live="polite" 新しい到着日と送料
08 規約のまとまり indeterminate いくつ点いているか
09 決済バー role="region"・スクロール枠のtabindex 注文フォーム領域と下部バーの存在

フォーム本体がまだならフォームバリデーションUI 9選が入力側の隣人で、複数ステップの注文フォームは多段階フォームウィザード 9選にまとまっています。入力部品のまとめは入力欄デザイン 9選にあり、表形式の注文一覧はデータテーブル操作 9選が隣人です。このサイトがデモをどう描画し測ってから本文を書くかは、紹介ページに書いてあります。

FAQ

カートの行を消さずに畳む理由は何ですか?

行をDOMからすぐ消すと、下の内容が一度に跳ね上がって、何が消えたかを見失います。grid-template-rows: 0frで畳むと行の場所が滑らかに縮むので目はその動きを追い、同じ商品が相手のリストに現れるので「移った」という事実そのものが動きで説明されます。

クーポンの適用ボタンを押しても割引行が出ないのはなぜですか?

割引行が入る枠はgrid-template-rows: 0frから始まりますが、内側の要素にmin-height: 0がないと枠は内容の高さまで開いたままになります。05で示した.pm__detailのように、畳まる枠の内側には常にmin-height: 0overflow: hiddenを一緒に置くと0frと1frの間を行き来します。

決済画面に九つ全部を置くべきですか?

いいえ、九つは材料です。最少構成は01(数量と合計)・08(同意)・09(決定バー)の三つで、クーポンを取るなら03、配送先が注文者と別なら06を加えます。一枚に九つ全部を載せると絵が込み入るので、決済の流れの一歩ごとに一つか二つに分けて貼ることを勧めます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。