GODRICH

カレンダー 選択 UI 9選 — コピペOK

カレンダー 選択 UI は、値一つではなく開始日と終了日のような範囲をまとめて選ばせる部品です。セグメントコントロールに続き、範囲・アバター・検索・ホイール・比率まで、実際の実装で詰まりやすい選択UI9種を、コードとともにまとめました。

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

9つは人気順ではなく、一度に選ぶ値の数が増える順番で並べました。01・02は一つの範囲(開始〜終了、最小〜最大)を選び、03・04・05はカード・アバター・チップで一つの選択肢を選び、06・07は検索やスクロールで一つの項目を探し、08は比率を、09は複数の項目を一度に別リストへ移します。

01カレンダー範囲ドラッグ選択

月のグリッドで開始日を選び、続けて終了日を選ぶと、その間の日付が薄く塗られ、両端だけ濃い点になります。宿泊予約や休暇申請の期間選択に使えます。

방향키 이동aria-selected0.22초
.cal__day.is-range { background: rgba($color, .18); }
.cal__day.is-edge {
  background: $color;
  color: #fff;
  font-weight: 700;
  transform: scale(1.06);
}

02デュアルサム価格帯スライダー

二つのつまみをドラッグ、または矢印キーで動かすと最小・最大の値が更新され、その間のトラックだけ色で埋まります。ECサイトの価格帯フィルターや、面積・坪数の範囲フィルターに向いています。

pointer 드래그role=slider방향키 ±1
function pctFromX(x) {
  var r = track.getBoundingClientRect();
  return Math.round(Math.max(0, Math.min(100, ((x - r.left) / r.width) * 100)));
}
function setLo(n) { v.lo = Math.max(0, Math.min(n, v.hi - 5)); paint(); }
function setHi(n) { v.hi = Math.min(100, Math.max(n, v.lo + 5)); paint(); }

03カード型プランラジオ選択

:has()で選んだカードだけが少し浮き上がって枠が明るくなり、右上にチェックバッジがスケールで飛び出します。料金プランカードや配送方法カードの選択に使えます。

:has()scale 배지0.22초
.plan:has(.plan__radio:checked) {
  transform: translateY(-3px);
  border-color: $color;
  box-shadow: $shadow-raised;
}
.plan:has(.plan__radio:checked) .plan__badge { transform: scale(1); opacity: 1; }

04アバターアイコングリッドピッカー

グリッドのアバターを一つ選ぶと、そのアバター固有の色でリングが大きくなり、上のプレビュー名も一緒に変わります。プロフィールアバターの選択やキャラクター選択画面に向いています。

role=radiogroup방향키 순환색상별 링
function select(idx) {
  cur = idx;
  items.forEach(function (b, i) {
    b.setAttribute('aria-checked', i === idx ? 'true' : 'false');
    b.tabIndex = i === idx ? 0 : -1;
    b.classList.toggle('is-on', i === idx);
  });
  pvEmoji.textContent = items[idx].dataset.emoji;
  pvName.textContent = items[idx].dataset.name;
}

05サイズチップ+数量ステッパーの組み合わせ

サイズチップを選ぶと単価が変わり、+/-ステッパーで数量を変えるとその場で小計が再計算されます。ECサイトでサイズと数量を同じ画面で決める商品オプションに使えます。

aria-live 수량min/max 비활성화0.22초
function price() {
  return +(radios.filter(function (r) { return r.checked; })[0] || radios[0]).dataset.price;
}
function paint() {
  num.textContent = qty;
  dec.disabled = qty <= 1; inc.disabled = qty >= 9;
  total.textContent = (price() * qty).toLocaleString('ja-JP') + '円';
}

06検索コンボボックスリストボックス

入力欄に文字を打つと、その文字で始まる項目だけに絞られてリストが開き、矢印キーで動いた項目はaria-selectedで示されます。国・都市の検索選択欄や、宛先を検索して選ぶ場面に向いています。

role=comboboxaria-selected0.18초
function render(q) {
  shown = all.filter(function (c) { return c.indexOf(q) === 0 || !q; }).slice(0, 3);
  list.innerHTML = '';
  shown.forEach(function (c, i) {
    var li = document.createElement('li');
    li.setAttribute('role', 'option');
    li.setAttribute('aria-selected', i === active ? 'true' : 'false');
    li.textContent = c;
    list.appendChild(li);
  });
}

07スクロールスナップ数字ホイールピッカー

縦にスクロールすると中央の枠に来た数字だけ大きく濃くなり、指を離す瞬間scroll-snapがその数字にぴったり止まります。アラーム時刻の時(じ)選択や、予約人数を選ぶホイールに使えます。

scroll-snap-type방향키 스크롤role=listbox
function mark() {
  var idx = Math.round(track.scrollTop / H);
  idx = Math.max(0, Math.min(N - 1, idx));
  if (idx === centered) return;
  centered = idx;
  items.forEach(function (el, i) { el.classList.toggle('is-center', i === idx); });
  track.setAttribute('aria-activedescendant', items[idx].id);
}

08アスペクト比クロッププリセットピッカー

1:1・4:5・16:9・9:16のチップから一つを選ぶと、プレビューボックスのaspect-ratioがその比率へなめらかに変わります。プロフィール写真のクロップ比率選択や、リール・ショート動画のアップロード比率選びに向いています。

aspect-ratio 전환role=radiogroup0.3초
function apply(v) { box.style.aspectRatio = v; }
radios.forEach(function (r) {
  r.addEventListener('change', function () { apply(r.value); });
});
.ar__box {
  aspect-ratio: 1 / 1;
  height: 100%;
  border: 2px solid $color;
  transition: aspect-ratio $duration $easing;
}
.ar__radio:checked ~ span { background: $color; color: #fff; }

09左右移動デュアルリストピッカー

左のリストで項目を選び矢印ボタンを押すと右のリストへ移り、矢印キーでリスト内を移動できます。権限・役割の付与画面や、メール配信グループの構成に使えます。

role=listbox다중 선택aria-multiselectable
function move(from, to, sel) {
  var keep = [], moved = [];
  from.forEach(function (n) { (sel[n] ? moved : keep).push(n); });
  from.length = 0; Array.prototype.push.apply(from, keep);
  Array.prototype.push.apply(to, moved);
  Object.keys(sel).forEach(function (k) { delete sel[k]; });
  render();
}

落とし穴 — 隠した装飾レイヤーはどこではみ出すか

01を最初に作ったとき、実際のクリック処理とは別にtranslateXだけで動く装飾用のスイープバーを::beforeで重ね、デモのループを独立して回そうとしました。ところがこのバーにtranslateX(366%)を与えると、見た目には舞台の外に出て見えないのにdocument.documentElement.scrollWidthはその分だけ伸びてしまい、480×300でも320px幅でもスクロールバーが生じる「はみ出し」判定を受けました。opacity: 0pointer-events: noneで隠しても、レイアウト上の位置(と移動距離)はそのまま計測されるためです。結局この装飾レイヤーを消し、実際の.is-range.is-edgeクラスが500msごとに切り替わる本物の状態変化だけをループに残しました。06の検索リストも似た理由で、閉じた状態でもリストの高さがそのまま残り、初回描画ではみ出しが検出されました — max-height: 0; overflow: hidden;で閉じた状態の箱を実際に畳まないと消えません。03が使う:has()Chrome・Edge 105+、Safari 15.4+、Firefox 121+でしか動かず、それより下のバージョンではカードが浮き上がらないだけで、ラジオの選択自体は正常に動きます。9つのファイルがそのまま入ったzipの圧縮パスワードはuv8qjazcです。

アクセシビリティ

03・05・08は本物の<input type="radio">を見た目だけ隠しているので、スクリーンリーダーはラジオとしてそのまま読み上げ、矢印キーで選択肢を移動できます。01はrole="gridcell"を持つ日付マスのaria-selectedを選択のたびに直接書き換えるので、ネイティブのラジオではありませんが選択状態は同じように伝わります。02・04・09はラジオではないカスタムコントロールなので、それぞれrole="slider"role="radiogroup"role="listbox"aria-valuenowaria-checkedaria-selectedを状態が変わるたびに更新します。06は入力欄のフォーカスを移さずに、強調した項目へaria-selectedを付け替えて示し、07も同じくフォーカスを移さずにaria-activedescendantで中央に来た項目を読み上げます。05の数量はaria-live="polite"なので、ボタンを押すたびに新しい値が静かに読み上げられます。prefers-reduced-motion: reduceでは9つ全てのトランジションを止め、チェック・リング・バッジといった選択状態は開いた・選ばれた最終的な見た目のまま残るので、情報そのものは消えません。

@media (prefers-reduced-motion: reduce) {
  .cal__day, .plan, .ava__item, .ar__box { transition: none; }
}

選択UI以外の単品コンポーネントはセグメントコントロールUI 9選にもあり、同じ選択UIでもセレクト・ドロップダウン系はセレクトボックス デザイン css 9選にまとめています。

FAQ

カレンダー範囲選択と、開始日・終了日を別々に入力する方式は何が違いますか?

日付欄を二つ別々に置くと値はそれぞれ独立して入りますが、01のようにグリッド一つで開始日と終了日を続けて選ぶと、その間の日付が即座に塗りつぶされ、何日間の期間なのかが一目で分かります。宿泊予約のように期間の長さ自体が重要な画面ほど、グリッド型が有利です。

デュアルサムスライダーで二つのつまみが近づきすぎたらどうなりますか?

02のsetLosetHiは常に最小値を最大値より5以上小さく保ちます。片方のつまみをもう一方の近くまで押すと、反対側のつまみも最小間隔ぶんだけ一緒に押され、二つのつまみが重なったり順序が入れ替わったりしません。

スクロールスナップのホイールピッカーは、マウスホイールがなくても使えますか?

はい。07のトラックにはscroll-snap-type: y mandatoryが付いているのでタッチスワイプでも自然に止まり、トラックにフォーカスして矢印キーを押すとscrollBy({ top, behavior: 'smooth' })で一段ずつ動きます。マウスホイールは同じスクロールを起こすもう一つの手段にすぎません。

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

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