セレクトボックス デザイン css 9選 — 実際に開く
セレクトボックス デザイン css とは、ブラウザが勝手に描く標準の <select> を捨て、ボタンとリストを自分で組み立てる作り方です。9つのうち1つは最後までリストを開かず、その売り切れのマスは押しても動きません。実際に開くものだけを並べました。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スプリングで開く基本セレクト
- 02 キーボードハイライトレール
- 03 検索して絞るコンボボックス
- 04 グループ見出しが貼り付くリスト
- 05 複数選択チップトークン
- 06 国旗付きの国・通貨セレクト
- 07 カラースウォッチ格子セレクト
- 08 アバター担当者アサイン
- 09 開かないサイズセグメント
並び順は人気順ではなく、セレクトボックスが実際にたどる順番です。まず開き(01)、開いたリストの中をキーボードで移動し(02)、選択肢が増えればスクロールより入力で絞り(03)、それでも多ければグループに分けます(04)。次は答えが1つではなく複数になる場合(05)です。最後の4つは、選ぶ値がそもそも文字ではないときのものです。国は国旗(06)、ラベルは色の丸(07)、担当者はイニシャル(08)で見分け、選択肢が4つしかないサイズ(09)は開く動作そのものを捨てます。9つすべてに role="listbox" と role="option" が付き、矢印キーでの移動は飾りではなく実際に効きます。
01スプリングで開く基本セレクト
ボタンを押すとリストが上端を起点にバネのように広がって実際に開き、項目を選ぶとボタンの文字がその値に変わります。この弾む感じは、transform-origin をパネルの上端に置き、scaleY を 0.72 から 1 へ押し上げて作っています。
.ss__list {
transform-origin: top center;
transform: translateY(-6px) scaleY(.72);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ss.is-open .ss__list { transform: translateY(0) scaleY(1); opacity: 1; pointer-events: auto; }
02キーボードハイライトレール
上下の矢印キーを押すと、色の付いたバーが1本だけ行の間を滑って移動し、Home と End で両端まで一気に飛びます。行ごとに背景を塗り替える作りだと4回の操作で4か所を塗り直すことになりますが、1本を translateY で動かせば変わるのは面積ではなく位置だけです。
function move(i) {
root.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
rail.style.transform = 'translateY(' + (at * ROW) + 'px)';
list.setAttribute('aria-activedescendant', opts[at].id);
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
}
list.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Home') { move(0); e.preventDefault(); }
else if (e.key === 'End') { move(opts.length - 1); e.preventDefault(); }
});
03検索して絞るコンボボックス
文字を打つとリストが実際に短くなり、一致した文字だけに蛍光ペンのような背景が付きます。絞り込んで何も残らなければ行の代わりに「該当なし」の一文が出て、Escape を押すと入力が消えて全部の行が戻ります。
function render(q) {
root.classList.remove('is-demo');
var shown = 0;
opts.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) {
shown++;
var k = names[i].indexOf(q);
o.innerHTML = !q ? names[i]
: names[i].slice(0, k) + '<span class="cb__m is-on">' + q + '</span>' + names[i].slice(k + q.length);
}
});
empty.hidden = shown > 0;
at = -1;
}
04グループ見出しが貼り付くリスト
リストをスクロールすると、今見ているグループの見出しが上部に貼り付いたまま残り、次のグループが来ると押し上げられます。貼り付ける仕事は position: sticky の1行が担当し、リストの箱には見せかけではない 68px の実スクロール領域を持たせています。
.gs__scroll {
height: 68px;
overflow-y: auto;
scrollbar-width: thin;
overscroll-behavior: contain;
}
.gs__head {
position: sticky;
top: 0;
z-index: 1;
box-sizing: border-box;
height: 20px;
margin: 0; padding: 0 $sp-2;
display: flex; align-items: center;
background: #fff;
font-size: 9px; font-weight: 800;
letter-spacing: .08em;
color: rgba(23, 20, 26, .38);
}
05複数選択チップトークン
選んだ行が入力欄の中にピル形のチップとして積み上がり、チップの x を押すか入力欄で Backspace を打つと、最後の1つから外れます。リストには aria-multiselectable="true" が付いているので、2つ目を選んでも1つ目が解除されません。
field.addEventListener('keydown', function (e) {
if (e.key === 'Backspace') {
var last = chips.querySelector('.mc__chip:last-child');
if (last && last.dataset.for) sync(document.getElementById(last.dataset.for));
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
field.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' || e.key === ' ') { sync(opts[at]); e.preventDefault(); }
else if (e.key === 'Escape') { root.classList.remove('is-open'); field.blur(); }
});
06国旗付きの国・通貨セレクト
国を選ぶと、ボタンの国旗と通貨表示が同じ動きの中で入れ替わります。国旗は画像ファイルでも絵文字でもなく、viewBox="0 0 16 12" の中に長方形2〜3個か円1個を置いたインライン SVG です。そのため、国旗の絵文字が2文字のアルファベットで表示される Windows の Chrome でも、ちゃんと国旗のまま出ます。
function pick(i) {
at = i;
root.classList.remove('is-demo');
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
swap.innerHTML = opts[i].querySelector('.cf__flag').innerHTML;
name.textContent = opts[i].querySelector('.cf__opt-name').textContent;
cur.textContent = opts[i].dataset.cur;
open(false);
btn.focus({ preventScroll: true });
}
07カラースウォッチ格子セレクト
名前が縦に並ぶリストではなく、横4つの色の丸の格子が開き、チェックマーク1つが選んだ丸の上へ跳びます。スウォッチごとにチェックを置くのではなく1つを動かすので、色をいくつ増やしても動く要素は1つのままです。
function place(i) {
ring.style.transform = 'translate(' + ((i % 4) * STEP) + 'px,' + (Math.floor(i / 4) * STEP) + 'px)';
}
function pick(i) {
root.classList.remove('is-demo');
at = i;
sws.forEach(function (s, k) { s.setAttribute('aria-selected', String(k === i)); });
place(i);
preview.innerHTML = '<span class="cs__dot" style="background:' + sws[i].style.background + '"></span>';
name.textContent = sws[i].getAttribute('aria-label');
}
08アバター担当者アサイン
どの行にもイニシャルの円と在席状況を示す点が並び、1人を選ぶとボタンの空のアバター枠がその人で埋まります。その空の枠は手で描いた人の形ではなく、Phosphor Duotone の user アイコンの SVG をそのまま貼ったものです。
function pick(i) {
root.classList.remove('is-demo');
at = i;
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
var pic = opts[i].querySelector('.av__pic');
wrap.innerHTML = '<span class="' + pic.className + '">' + pic.textContent + '</span>';
name.textContent = opts[i].querySelector('.av__who').textContent;
open(false);
btn.focus({ preventScroll: true });
}
09開かないサイズセグメント
選択肢が4つなので何も開かず、ピル1つがマスの間を滑ります。売り切れのマスには aria-disabled="true" が付いているのでクリックしてもピルは動かず、同じガードが矢印キーでの移動も止めます。
function pick(i) {
if (i < 0 || i >= cells.length) return;
if (cells[i].getAttribute('aria-disabled') === 'true') return;
root.classList.remove('is-demo');
at = i;
pill.style.transform = 'translateX(' + (i * W) + 'px)';
cells.forEach(function (c, k) { c.setAttribute('aria-selected', String(k === i)); });
track.setAttribute('aria-activedescendant', cells[i].id);
}
どこで壊れるか — 落とし穴
開いたリストを position: absolute で浮かせた瞬間、そのリストは親の高さの計算から丸ごと抜け落ちます。中央揃えはラベルとボタンだけで計算され、リストは親が思っている箱の下へはみ出します。画面が広いうちは周りの余白が隠してくれます。幅 320px で測ると9つのうち6つで文書の高さが画面を超え、01 番は画面 200px に対して中身が 230px でした。30px でも最後の行を切り落とすには十分な長さです。直し方はリストを流れの中に戻すことではなく、開いたパネルの高さ分の場所を先に取っておくこと、つまりルート要素に padding-bottom を置くことでした。値は項目ごとに違い、01 番は 116px、04 番は 80px です。
2つ目の落とし穴は 04 番のものです。position: sticky は本物のスクロールがないと発動しません。自動再生の収録ではリストを transform で押し上げるため、スクロール位置は 0 のままで、見出しも一緒に上がってしまい、いちばん見せたい瞬間が記録されません。そこで自動再生のときだけ見出しに逆向きの移動を与えて貼り付いて見えるようにし、人がホイールやキーボードで実際にスクロールするときは手を加えていない position: sticky がそのまま働きます。この2つを直した状態のソースは、解凍パスワード c7dd2vxx で開く zip の中に9つ分そろえてあります。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では自動再生のループを止め、状態の値はそのまま残します。バーは滑らずに行へ着き、パネルは広がる動きなしに開いた姿で現れ、どれが答えかは aria-selected が言い続けます。トリガーには aria-haspopup="listbox" と aria-expanded が付いて開閉が読み上げられ、開いたリストは aria-activedescendant でフォーカスを移さずに指している行だけを変えます。ここで使ったロールと属性の定義は MDN の listbox ロールの解説にまとまっています。フォーカスを戻すときに必ず focus({ preventScroll: true }) を使うのは、これらのデモが一覧ページに iframe で埋め込まれていて、素の focus() だと読んでいる人の画面が下へ引っ張られるからです。
同じ場所で使う入力部品はフォーム・入力カテゴリに、押すと返事をする部品はクリックカテゴリにまとめてあります。
FAQ
標準の <select> を使わない理由はありますか
見た目が気に入らないだけなら、理由にはなりません。標準のコントロールはどのスクリーンリーダーもキーボードもすでに理解していますし、スマホでは OS が自前では作れない品質のシートを出してくれます。自分で作るのは、選択肢が文字ではないとき、つまり国旗・色の丸・イニシャルのときか、答えが同時に複数になるときです。その場合は標準が担っていた仕事を、役割もキー操作も含めて丸ごと引き受けることになります。
9つともマウスなしで使えますか
使えます。トリガーには Tab でフォーカスが移り、Enter か Space で開き、開いたリストは上下の矢印で移動して Enter で確定し、Escape で閉じるとフォーカスがトリガーに戻ります。02 番は Home と End が加わり、07 番は左右の矢印で隣のスウォッチ、上下の矢印で1つ上の段と1つ下の段に移動します。
React 版でもアニメーションの値を変えられますか
変えられます。9つのコンポーネントはすべて duration・easing・color を props で受け取り、--duration・--easing・--color のカスタムプロパティとして渡します。スタイルシートはこの変数しか読まないので、値を1つ変えれば9つが同じリズムで動きます。