GODRICH

料金表 デザイン 実装9選 — 切り替えも計算も動く

料金表 デザインで実際に効くのはカードの形ではなく、その上で起きるインタラクションです。9 つはすべてライブラリなしの素の JS と SCSS だけで動き、最初の 1 つは支払い周期を切り替えたときに数字が実際に変わる、その動作だけを入れました。

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

並び順は人気順ではなく、人が料金表で金額を決めていく順です。まず支払い周期を選び(01)、自分の国の通貨に換え(02)、使う量を入れて料金を計算します(03)。金額が決まったら選ぶ段階です。おすすめプランが前に出て(04)、見ているカードがカーソルに追従して傾き(05)、何が含まれるのかが一行ずつ埋まります(06)。最後の 3 つは決める直前です。表を開いて項目ごとに突き合わせ(07)、狭い画面ではカードを横に送り(08)、ボタンを押して答えを待ちます(09)。9 つに共通する決まりは 1 つだけです。値を持つのは必ず aria-checkedaria-selecteddata-* といった属性で、CSS はその属性しか見ません。JavaScript がクラスやインラインスタイルを直接触り始めると、画面の見た目と支援技術が読む状態はすぐにずれていくからです。

01月・年の切り替えで数字が回る

年額に切り替えると月額 29 ドルが月あたり 23 ドルになりますが、値が一瞬で入れ替わるのではなく、一桁ずつ転がって下がります。登録していないカスタムプロパティは補間できないので、@property--n を整数として登録し、その値をカウンターで描いています。24 フレームのレンダリングで動いたフレームは 19 枚、強度は 110.4 で、03(111.0)に次ぐ高さです。

@propertycounter-resetaria-checked
@property --n {
  syntax: "<integer>";
  initial-value: 29;
  inherits: false;
}

.bt__num {
  --n: 29;
  counter-reset: n var(--n);
  font-size: 40px;
  line-height: 46px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}
.bt__num::after { content: counter(n); }

02通貨を変えると記号と金額が同時に変わる

一覧から通貨を選ぶと、トリガーのコードと記号、そして左側の金額が一度に切り替わります。金額の 4 枚は同じ位置に重ねて置き、ルートの data-cur 1 つだけがどれを見せるかを決めます。選んだあとトリガーへフォーカスを戻すときは必ず preventScroll を付けてください。この部品は一覧ページで iframe の中に入るためです。

aria-expandedaria-selectedpadding-bottom
function pick(li) {
  root.classList.remove('is-demo');
  root.setAttribute('data-cur', li.getAttribute('data-cur'));
  opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === li)); });
  code.textContent = li.getAttribute('data-code');
  tsym.textContent = li.getAttribute('data-sym');
  open(false);
  trig.focus({ preventScroll: true });
}

03使う量に合わせて価格を計算し直す

つまみを動かすと席数が 3・5・10・20・50 と変わり、料金が 1 席あたり 8 ドルで計算し直されます。目に見えるのは数字ですが、スクリーンリーダーに届くのは aria-valuetext の文なので、読み上げは「5」ではなく「5 席」になります。累積面積は 5.301%、動いたフレームは 21 枚で、バーの塗りと数字が一緒に動くぶん空きフレームがほとんどありません。

aria-valuetextscaleXtransform-origin
var SEATS = [3, 5, 10, 20, 50];
function apply(v) {
  var seats = SEATS[v - 1];
  root.style.setProperty('--p', String(v));
  seatn.textContent = String(seats);
  fee.textContent = '$' + (seats * 8);
  range.setAttribute('aria-valuetext', seats + ' ' + '席');
}
range.addEventListener('input', function () {
  root.classList.remove('is-demo');
  apply(parseInt(range.value, 10));
});

04おすすめプランだけ大きくなり隣は下がる

どのカードでもカーソルを乗せるとそのカードが 1.06 倍で前に出て、残りの 2 枚が 0.94 倍に下がります。下がる側のルールは :has() のぶん詳細度が高いので、前に出すルールにも同じ :has() を付けないと勝てません。リボンはカードの上へ 16px はみ出す絶対配置なので、ルートにそのぶんの余白を先に確保してあります。

:has()scalerotate
.pr__row:has(.pr__card:hover) .pr__card,
.pr__row:has(.pr__card:focus-within) .pr__card { transform: scale(.94); }
.pr__row:has(.pr__card:hover) .pr__card:hover,
.pr__row:has(.pr__card:focus-within) .pr__card:focus-within { transform: scale(1.06); }

.pr.is-demo .pr__card {
  animation-name: pr-lift;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-fill-mode: backwards;
}
.pr.is-demo .pr__card[data-plan="starter"]  { animation-delay: 0ms; }
.pr.is-demo .pr__card[data-plan="pro"]      { animation-delay: 300ms; }
.pr.is-demo .pr__card[data-plan="business"] { animation-delay: 600ms; }

05カーソルに合わせて傾くプランカード

カード内でのカーソルの相対座標を 0 から 1 の範囲で読み、それを 2 軸の角度に変えて、同じ座標をグレアの中心にも使っています。傾きを 9 度で止めているのは、角度が大きくなるほどカードが真横から見た絵に近づくからです。カーソルを追っている間は transition を付けず、離れるときだけ付くので、カーソルが離れたカードだけがゆっくり元に戻ります。

perspectiverotateYpointermove
var LIMIT = 9; // deg
function tilt(e) {
  root.classList.remove('is-demo');
  card.classList.remove('is-idle');
  var r = card.getBoundingClientRect();
  var x = (e.clientX - r.left) / r.width;
  var y = (e.clientY - r.top) / r.height;
  card.style.setProperty('--ry', ((x - 0.5) * 2 * LIMIT).toFixed(2) + 'deg');
  card.style.setProperty('--rx', ((0.5 - y) * 2 * LIMIT).toFixed(2) + 'deg');
  card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');
  card.style.setProperty('--my', (y * 100).toFixed(1) + '%');
}
scene.addEventListener('pointermove', tilt);

06含まれる機能が一行ずつ点く

5 行が上から 160 ミリ秒ずつ遅れて点きます。ここで animation: のショートハンドを書くと、その宣言が animation-delay を 0 に戻して 5 行が同時に動いてしまうので、個別プロパティだけで書いています。遅延の区間ではアニメーションがまだ適用されないので、完成した状態の行が最初のフレームで一瞬見えてしまいますが、animation-fill-mode: backwards が 0% のキーフレームでその区間を覆って防ぎます。

animation-delayanimation-fill-mode--i
.fc.is-demo .fc__row {
  animation-name: fc-line;
  animation-duration: $dur-loop;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
  animation-delay: calc(var(--i) * #{$stagger});
  animation-fill-mode: backwards;
}
@keyframes fc-line {
  0%   { opacity: .26; transform: translateX(-14px); }
  12%  { opacity: 1; transform: translateX(0); }
  74%  { opacity: 1; transform: translateX(0); }
  86%  { opacity: .26; transform: translateX(-14px); }
  100% { opacity: .26; transform: translateX(-14px); }
}

07比較表がグループごとに畳まれる

グループ見出しを押すと下の行が畳まれますが、高さを測るコードは一行もありません。セルの内側の箱が 1 トラックのグリッドで、そのトラックが 1fr から 0fr へ補間されるだけです。マークアップは <th scope="col"><th scope="row"> を持つ本物の表のままなので、畳まれていても行と列の見出しの関係が残ります。

grid-template-rowsscope="row"aria-controls
.ca__box {
  display: grid;
  grid-template-rows: 1fr;
  overflow: hidden;
  transition: grid-template-rows $duration $easing;
}
.ca__group[data-open="0"] .ca__box { grid-template-rows: 0fr; }
.ca__group[data-open="0"] .ca__caret { transform: rotate(-90deg); }

08指でめくるプランカード

カードは 3 枚だけで、複製も巻き戻しもありません。ドラッグしている間はトラックの transition を外して --dx だけを更新し、指を離したら移動距離をカード 1 枚ぶんの幅に間隔 12px を足した値で割って四捨五入し、近いカードへ吸い付かせます。スクロールする箱とトラックは必ず別の要素にしてください。overflow: hidden で切り取る窓がその箱そのものなので、箱ごと動かすと窓が中身と一緒に移動してしまい、スクロールが起きないからです。

setPointerCapturetranslateXaria-current
.sw__view {
  overflow: hidden;
  width: 100%;
  border-radius: $r-card;
  touch-action: pan-y;
  cursor: grab;
}

.sw__track {
  --i: 0;
  --dx: 0px;
  --base: calc(-1 * var(--i) * (100% + #{$gapx}));
  display: flex;
  transform: translateX(calc(var(--base) + var(--dx)));
  transition: transform $duration $easing;
}
.sw__track.is-drag { transition: none; }

09決済ボタンが待ってから結果を見せる

3 つの顔が同じ位置に重なっていて、data-state 1 つがどれを見せるかを決めます。待っている間ボタンは aria-busy="true" かつ disabled なので二度押しが入らず、見えていない顔は aria-hidden なのでライブ領域は新しい文を 1 つだけ読みます。読み込みが 1.2 秒、成功の印がさらに 1.2 秒で、そのあと既定の顔に戻ります。

aria-busysteps(1, end)aria-live
function state(name) {
  root.classList.remove('is-demo');
  root.setAttribute('data-state', name);
  btn.setAttribute('aria-busy', String(name === 'busy'));
  btn.disabled = name !== 'idle';
  hidden.forEach(function (el) {
    if (el.getAttribute('data-face') === name) { el.removeAttribute('aria-hidden'); } else { el.setAttribute('aria-hidden', 'true'); }
  });
}
btn.addEventListener('click', function () {
  timers.forEach(clearTimeout);
  timers = [];
  state('busy');
  timers.push(setTimeout(function () { state('done'); }, 1200));
  timers.push(setTimeout(function () { state('idle'); }, 2400));
});

どこで壊れるのか — 滑る値と切り替わる文字がずれる

この回でいちばん時間を取られたのはコードではなく代表画像でした。このブログの代表画像には、2 秒を 24 枚に割ったフレームのうち前の 1 枚から最も大きく変わった 1 枚が選ばれます。03 の最初の版はその規則にそのまま引っかかりました。つまみは --p に沿って目盛りと目盛りの間をなめらかに滑るのに、席数と料金は steps(1, end) で切り替わるため、値が到着する時刻にカットを置くと滑る 4 フレームのあいだ数字だけが遅れます。実際に選ばれた 1 枚は、つまみが 5 番目の目盛りの手前なのに文字は 20 席・160 ドルという場面でした。直し方はカットを到着時刻ではなく目盛りと目盛りのちょうど中間へ移すことでした。step スライダーが実際に値を変えるのがその中間だからで、そうするとずれ幅が半分になり、代表画像は 2 番目の目盛りの上で 5 席・40 ドルと一致しました。同じ症状が 08 の点にもありました。カードが到着する 6% 手前で点が先に切り替わるので、カードはまだ 1 枚目なのに点は 2 枚目を指す場面が代表画像になっていました。点のカットをカードの到着時刻と同じ 36%・64% に合わせる、それだけが答えでした。06 は逆向きの落とし穴でした。5 行を親から一度に透明にして次の周回を始めたところ、その 1 フレームが圧倒的に大きく変わった 1 枚になり、代表画像がほぼ空のカードになりました。消えた行も 26% の不透明度で残し、消す仕事も行ごとの時間差に任せたところ、いまの値は累積面積 3.764%、動いたフレーム 17 枚で、どの 1 フレームも単独で画面を占めることがなくなりました。最後の 1 つは 07 で、畳まれるセルに縦のパディングを与えるとトラックが 0fr になってもそのぶんの 12px が残り、最後まで閉じません。セルの高さは line-height だけで作り、箱の中のセルに min-height: 0 を置くと底まで閉じます。4 つともソースを読んでも見えず、フレームを 1 枚ずつ開いて初めて見えてくる類いのものなので、9 枚の代表画像をすべて目で開いて確かめてから先へ進みました。この 9 つを収めた zip の解凍パスワードは whhp3vgx で、中には素の実装 9 組と React 版 9 組が同じ名前で入っています。

アクセシビリティ — reduced-motion で何が残るか

9 つとも prefers-reduced-motion: reduce で自動再生ループと装飾のアニメーションを止め、状態の値はそのまま残します。01 はスイッチが滑る代わりに一瞬で位置に着き、aria-checked はそのまま反転します。07 は grid-template-rows の補間なしで即座に開閉します。04 と 05 は transition を外すだけなので、大きさも角度もカーソルには反応しますが、途中の段階を踏まずに一気にその値へ跳びます。値を伝える側は動きに関係なく生きています。01 と 03 の価格欄は aria-live="polite"、03 のスライダーは aria-valuetext、09 のボタンは aria-busy と案内文の aria-live が状態を伝えます。07 の表は列見出しに scope="col"、行見出しに scope="row" を置いているので、畳まれた状態でも関係が残ります。

文字のコントラストは WCAG の相対輝度の式で直接計算しました。半透明のカード背景は、ステージの色と合成した実効色で測っています。

使う場所 文字 背景
01 価格の数字・青/白いカード #2f6df6 #ffffff 4.53:1
01・03・04・07 本文の墨/白いカード #17141a #ffffff 18.24:1
02 金額・ミント/墨のカード #4cd4a6 #252228 8.43:1
04 おすすめカードの文字・純白/青 #ffffff #2f6df6 4.53:1
05 プラン名・ライラック/墨のカード #b9a0ff #2a272c 6.69:1
06 機能の行・クリーム/暗くしたオレンジのカード #fff7e6 #b83716 5.48:1
08 価格・スカイ/墨のカード #6ec8ff #27242a 8.29:1
09 成功の文字・ミント/墨のボタン #4cd4a6 #17141a 9.79:1

2 か所は最初に選んだ色が基準に届かず、選び直しました。04 のおすすめカードのクリームの文字は青の上で 4.25:1 だったので純白に上げて 4.53:1 にし、同じカードの単位の文字は不透明度 72% で 3.10:1 まで落ちていたので不透明度を外しました。06 はオレンジのステージの上のカードを黒 16% でしか暗くしておらず 4.25:1 だったため、28% まで暗くして 5.48:1 を確保しました。料金表は人がお金を払う画面なので、ここでコントラストを譲ると失うものがはっきりしています。隣の料金表の CSS アニメーション集は同じ題材を CSS だけで扱っていて、料金表のカテゴリにはこのシリーズの他の回が集まっています。登録の書式は MDN @property のリファレンスに従いました。

よくある質問

@property なしで価格の数字を転がせますか

登録しないとブラウザはカスタムプロパティをただの文字列として扱うので、29 と 23 の間に中間値がありません。@propertysyntax: "<integer>" を宣言して初めてその間が埋まります。登録が使えない環境なら、requestAnimationFrame の中で数字を自分で書き込む方法しか残らず、毎フレーム、テキストノードを差し替えることになります。

月・年のトグルにチェックボックスではなく role="switch" を使った理由は何ですか

支払い周期はオンとオフの値ではなく 2 つのうち 1 つを選ぶ値なので、ラジオも候補になります。ただし選択肢がちょうど 2 つで画面にはピルが 1 つだけという形なら、スイッチのほうが読み上げられる文が短くなります。3 つ以上になったら、そのときはラジオグループに変えてください。

無限カルーセルのように端から先頭へ巻き戻したい場合はどうしますか

08 はあえて巻き戻しません。巻き戻すには先頭カードの複製を末尾に付けておいて継ぎ目でこっそり位置を入れ替える方法がよく使われますが、その瞬間にスクリーンリーダーは同じカードを二度読み、キーボードフォーカスも複製に閉じ込められます。プランが 3 つだけの料金表なら、両端で止まるほうが失うものが少ないです。

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

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