GODRICH

検索窓 デザイン css 9選 — Cmd+Kで開くパレット

検索窓 デザイン css の中でもコマンドパレットは、画面の上に浮かんでコマンドと文書を1つの欄から探すオーバーレイです。9つのうち1つは該当なしのときにむしろ行が増え、もう1つはリストを持たず入力欄だけを先に出します。

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

並び順は人気順ではなく、人がパレットに触れる時間の順です。ショートカットで開いた直後、まだ何も打っていない状態で何を見せるか(01)が先で、次が打ち始めたときの一致のさせ方(02)です。リストが箱より長くなればスクロールがカーソルを追う必要があり(03)、行の右端には次回パレットを開かずに済むようショートカットのバッジが並びます(04)。コマンド1つで終わらなければ一段潜り(05)、名前だけでは選べなければプレビューが横に付きます(06)。07 は検索が外れたとき、08 と 09 は同じパレットを別の画面幅に置き直したものです。9つすべてに role="dialog"role="listbox" が付き、矢印キー・Enter・Escape は飾りではなく実際に効きます。

01開いた瞬間に出る最近の項目

ショートカットで開くと、まだ一文字も打っていないのに「最近」と「移動」の見出しの下に前回使ったコマンドが並びます。まとまりは role="group"aria-labelledby で束ねてあり、スクリーンリーダーは見出しをその行たちの名前として読みます。開閉のショートカットは Mac の ⌘ と Windows の Ctrl を両方見ています。

role=listboxrole=grouparia-labelledby
document.addEventListener('keydown', function (e) {
  var k = e.key.toLowerCase();
  if ((e.metaKey || e.ctrlKey) && k === 'k') {
    live();
    pal.classList.toggle('is-shut');
    if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
    e.preventDefault();
    return;
  }
  if (pal.classList.contains('is-shut')) return;
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && opts[at]) {
    live();
    opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
    opts[at].classList.add('is-run');
    e.preventDefault();
  } else if (e.key === 'Escape') {
    live(); pal.classList.add('is-shut');
  }
});

02飛ばし打ちでも当たる検索

文字を続けて打たず飛ばして打っても、順番さえ合えば当たります。探し方が部分文字列ではなく部分列なので、クエリの文字を1つずつ indexOf(c, from) で前へ前へと探し、1つでも見つからなければその行を外します。一致した箇所は区間ではなく一文字ずつ <mark> で包むことで、飛ばして当たったことが目に見えます。

markaria-activedescendantopacity
function hits(name, q) {
  var lower = name.toLowerCase(), need = q.toLowerCase();
  var idx = [], i = 0;
  for (var c = 0; c < need.length; c++) {
    var k = lower.indexOf(need[c], i);
    if (k < 0) return null;
    idx.push(k); i = k + 1;
  }
  return idx;
}
function paint(name, idx) {
  var html = '', last = 0;
  idx.forEach(function (k) {
    html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
    last = k + 1;
  });
  return html + name.slice(last);
}

03カーソルを追うスクロール

7行入っていますが箱は4行分しか見せず、カーソルが下端に触れた瞬間からリストが一行ずつ押し上がります。これをしているのは scrollIntoViewblock: 'nearest' 一行で、すでに見えている行では何もしないため最初の三回は画面がまったく揺れません。Home と End も受け取り、両端へ一度で飛びます。

scrollIntoViewblock: nearesttranslateY
function move(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
  // block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
  opts[at].scrollIntoView({ block: 'nearest' });
}

04右端で揃うショートカットバッジ

コマンド名が二文字でも六文字でも、キーキャップのバッジは右端で一列に揃います。揃えているのは名前側の flex ではなくバッジ側の margin-left: auto で、余った横幅を丸ごとそのマージンが飲み込みます。バッジに書いてある組み合わせを実際に打つとその行が動くので、早見表が絵で終わりません。

margin-left: autokbdtranslateY
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
  box-sizing: border-box;
  min-width: 16px; height: 16px;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 0 $sp-1;
  border-radius: $r-xs;
  background: rgba(255, 247, 230, .14);
  box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
  font-family: inherit;
  font-size: 9px; font-weight: 800;
  color: rgba(255, 247, 230, .72);
}

05一段潜るパンくず

先頭の行を選ぶと入力欄の前にピル型のチップが1つ生まれ、リストがそのコマンドの対象一覧に切り替わります。戻る道は戻るボタンではなく空の入力欄での Backspace なので、手が入力欄を離れません。今どの段にいるかは、画面から隠した aria-live="polite" の段落が読み上げます。

BackspacetranslateXaria-live
document.addEventListener('keydown', function (e) {
  var r = rows();
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
    pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
  } else if (e.key === 'Enter' && r[at]) {
    if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
    e.preventDefault();
  } else if (e.key === 'Backspace' && deep && !input.value) {
    deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
  } else if (e.key === 'Escape') {
    deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
  }
});

06右に付くプレビューパネル

カーソルのある行の中身が右半分にすぐ出て、行が変わるたびにパネルの中だけが丸ごと入れ替わります。左のリストと右のパネルは grid-template-columns: 1fr 104px の一行で分け、入力欄に aria-describedby を付けて今のプレビューが何かも読み上げられるようにしました。入れ替えは混ぜずに1フレームで切ります。

grid-template-columnssteps(1, end)aria-describedby
function show(i) {
  pal.classList.remove('is-demo');
  at = Math.max(0, Math.min(i, opts.length - 1));
  opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
  pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
  input.setAttribute('aria-activedescendant', opts[at].id);
}

07空振りで終わらない検索結果なし

一致する行が1つもないとき、案内を一行置いて終わらせず、今打った文字をそのまま含む行が2つ代わりに上がります。その2行にも role="option" が付いているので、矢印キーと Enter がそのまま効きます。案内文と選択肢はここでは別の物です。

hiddenrole=optionopacity
function render(q) {
  pal.classList.remove('is-demo');
  var shown = 0;
  rows.forEach(function (o, i) {
    var hit = !q || names[i].indexOf(q) >= 0;
    o.hidden = !hit;
    if (hit) shown++;
  });
  empty.hidden = !(q && shown === 0);
  // 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
  document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
  at = -1;
}

08画面中央の大きな入力欄

最初は大きな入力欄が1つ浮かんでいるだけで、文字が入った瞬間に下へ結果が開きます。開くのに使う値は高さでも scaleY でもなく clip-pathinset の下側の値で、パネルが占める 84px の場所は閉じているときも取ったままにしてあります。だから開いても下の内容が押されません。

steps(1, end)clip-path: inset()$ease-spring
.sp__panel {
  position: relative;
  box-sizing: border-box;
  height: 84px;
  margin: $sp-2 0 0;
  clip-path: inset(0 0 100% 0 round 16px);
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }

09下から上がるシート型パレット

スマホ幅では中央のモーダルの代わりに、シートが下から上がって画面の底に貼り付きます。上の角だけを丸め、いちばん上のつまみのバーは飾りではなく aria-label の付いた本物の閉じるボタンです。下のパディングに env(safe-area-inset-bottom) を足してあるので、ノッチのある画面でも最後の行がホームインジケータに掛かりません。

translateYrole=dialog$r-card
.ph__sheet {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  box-sizing: border-box;
  padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
  border-radius: $r-card $r-card 0 0;
  background: #fff;
  box-shadow: $shadow-raised;
  transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }

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

この回でいちばん手こずったのはアニメーションではなく、代表画像として切り出される一枚でした。ここで使っているレンダリングツールは2秒のループを24枚に割り、最初の一枚といちばん違う一枚を代表画像に選びます。06 番はカーソルが行と行の間を通っている最中に右のパネルが変わるよう組んであり、よりによってその入れ替えがいちばん大きな変化だったため、カーソルは2行目・パネルは3行目の中身という場面が代表画像に選ばれました。直し方は2つでした。まずカーソルが行に着いて止まってからパネルが変わるように時点をずらし、プレビューごとに背景色まで変えて、入れ替えが確実にいちばん大きな変化になるようにしました。ここでもう1つ引っかかったのですが、24枚の最後の一枚が2秒の 95.8% の地点なので、96% に置いていた「最初へ戻る入れ替え」はそもそも撮影されていませんでした。戻す時点を 90% まで前倒しして、ようやく一周が途切れなくなりました。

2つ目の落とし穴は入力欄の文字が二重になることです。02・07・08 番は自動再生で「人が打っている文字」を見せるため入力欄の上に文字を一枚重ねたのですが、その下の placeholder がそのまま残り、二つの文字が重なって読めない形になりました。重なって単語にならない欄が、そのまま代表画像になっていました。自動再生の間だけ ::placeholder を透明にすれば済む話でしたが、撮ったものを開いて見るまでどのゲートもこれを捕まえてくれませんでした。

3つ目はスマホ幅です。01 番は入力欄1つに見出し2つ、行4つで縦が 186px になりましたが、320×200 の画面では舞台の余白を引くと使える高さは 174px です。12px 足りず、スマホでは最後の行が切れていました。行の高さを 24px から 22px へ、見出しは line-height を 12px に固定し、カードの縦のパディングだけを 8px から 4px に減らして 164px に収めました。横のパディングはそのままなので、広い画面での印象は変わっていません。

4つ目は 08 番から出ました。結果パネルを閉じるとき最初は scaleY(0) を使ったのですが、上端を軸に縮めると中の文字まで一緒に潰れて平たくなります。半分ほど閉じた瞬間の文字が縦に潰れたまま残り、閉じる動作ではなく画面が壊れたように見えました。clip-path: inset() は要素を描いたうえで切り取るだけなので、文字の比率がそのまま残ります。代わりに overflow: hidden と違って丸い角を別に書く必要があり、そのために round 16px を付けています。この4つを反映したソースは、解凍パスワード vswx5meq で開く zip の中に9つ分そろえてあります。

アクセシビリティ

9つとも、フォーカスは入力欄に置いたまま動かします。矢印キーを押しても focus() が行へ移ることはなく、今指している行は入力欄の aria-activedescendant が変わることで伝わります。こうする理由は入力欄が文字を受け取り続ける必要があるからで、この組み立ての定義と要件は MDN の combobox ロールの資料にまとまっています。オーバーレイ自体には role="dialog"aria-modal="true" が付き、後ろのページではなくこの箱が今の文脈だと伝えます。

キー 9つでの働き
⌘K・Ctrl+K パレットの開閉(01・09)。metaKeyctrlKey を両方見れば Mac と Windows を一度に受けられる
↑ ↓ 行の移動。03 ではここで scrollIntoView({ block: 'nearest' }) も一緒に回る
Enter 立っている行の実行。aria-selected がその行へ移る
Escape 閉じる、または入力を空にする。フォーカスは focus({ preventScroll: true }) で戻す
Backspace 05 だけ特別で、入力欄が空なら文字ではなく一段を消す

prefers-reduced-motion: reduce では自動再生のループだけを止め、今の状態はそのまま残します。05 のチップは滑らずにその場に立ち、08 のパネルは切り取られる過程なしに開いた状態で現れ、09 のシートは上がる道なく貼り付いたままです。どの行が選ばれているかは、動きを全部止めても aria-selected が伝え続けます。フォーカスを戻すときに preventScroll を付けるのは、これらのデモが記事の中に iframe として入っていて、素の focus() を呼ぶと読んでいる人の画面がデモの方へ引っ張られるからです。

同じ画面で一緒に使う部品はダッシュボードのカテゴリに、押して開く部品はクリックのカテゴリにまとめてあります。

FAQ

検索欄1つになぜ role="dialog" まで付けるのですか

浮かんだ箱が後ろのページを覆った時点で、それは入力欄ではなく1つの画面になるからです。role="dialog"aria-modal="true" がないと、スクリーンリーダーは後ろのリンクやボタンをまだ使えるものとして案内し続け、使う人は自分がどこにいるか分からなくなります。逆に画面を覆わずページの中にそのまま座るだけの検索欄なら dialog は要らず、combobox だけで足ります。

部分列の検索は部分文字列より常に優れていますか

リストが短いときはむしろ不利です。飛ばして当てる方式は通る行が増えるので、選択肢が10個ほどより少ないと的外れな行が上に来ることが多くなります。02 番のように使うなら当たった箇所の数と間隔で点を付けて並べ替えまでした方がよく、この回の 02 番は並べ替えなしで元の順だけを守る最小の実装です。

9つを同じ画面に一緒に置いてもいいですか

ショートカットが重なっていないかだけ見てください。01 番と 09 番が同じ ⌘K を聞いているので、そのまま2つ載せると一度押しただけで両方開きます。zip の中では各デモが単体で動くようファイルが分かれているので、実際の画面に載せるときはショートカットを聞く場所を1つにまとめ、残りはその1つが開く形に変えるのが安全です。

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

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