本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
オーバーレイ

コマンドパレットが ⌘K で開き、打つたびに候補が絞られる

デモ

操作:クリック・カーソルを重ねる。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

⌘K(Windows は Ctrl+K)か検索欄のボタンで、後ろのページを暗くしてパレットを開きます。字を打つたびに、その文字列を含まない行の高さを 0 へ縮めて残りを詰め、一致した部分を太字にします。↑↓ で選んでいる行の地が移り、Enter か行を押すと、その行を押し込んでから閉じます。

パレットは不透明度を 0.12 秒で先に上げ、12px 上から 0.42 秒(power3.out)で降ろします。合わない行は 0.3 秒(power2.inOut)で縮めます。選んでいる行の地は 1 枚だけを行の後ろに置き、「詰めきった後の並びで何行目か × 行の高さ」の位置へ 0.24 秒で動かします。詰まる途中の行の位置を追うと、地が上下に揺れます。

パレットは、showModal() で開く <dialog> で作ります。焦点の閉じ込め・背面の締め出し・Esc・閉じたあとの焦点の戻しが付いてきます。入力欄は role="combobox" にして、選んでいる行を aria-activedescendant で伝えます。候補の数は、見えない status の領域に書いて読み上げに届けます。暗幕は CSS の ::backdrop で薄め、閉じるときもパレットと一緒に消します。

日本語の入力では、かな漢字変換を確定する Enter でもコマンドが動いてしまいます。keydown では isComposing を見て、変換中のキーを受けません。和文は字の種類が多いので、1 字目で候補が 1 件まで減りがちです。読みのかなでも引けるよう、各行に読みを持たせて両方で判定すると使いやすくなります。

一致の判定は部分一致です。候補が多いなら、あいまい検索のライブラリに替えてもかまいません。動きを減らす設定では、ほぼ一瞬で切り替えます。このデモでは、サイドバーの検索欄を押すと開き、行に重ねると選ぶ行が移り、押すと実行して閉じます(打ち込みと矢印のキーは、コードのほうで受けます)。管理画面・ドキュメント・SaaS の横断検索や、ショートカットの一覧に向いています。

コード

HTML
<!-- ⌘K(Windows は Ctrl+K)でも開く。キーボードを使わない人のために、押して開く口も置く -->
<button class="palette-open" type="button" aria-haspopup="dialog" aria-controls="command-palette" aria-keyshortcuts="Meta+K Control+K">
  <span>検索…</span>
  <kbd>⌘K</kbd>
</button>

<!-- showModal() で開く <dialog> には、焦点の閉じ込め・背面の締め出し・Esc・焦点の戻しが付いてくる -->
<dialog class="palette" id="command-palette" aria-label="コマンドパレット">
  <div class="palette__body">
    <div class="palette__field">
      <svg class="palette__lens" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <circle cx="11" cy="11" r="6" />
        <path d="M15.5 15.5 20 20" />
      </svg>
      <!-- 入力欄が候補の一覧を操る(combobox)。選んでいる行は aria-activedescendant で伝える -->
      <input class="palette__input" type="text" role="combobox" aria-label="コマンドを検索" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list" placeholder="コマンドを入力、または検索…" autocomplete="off" spellcheck="false">
      <kbd aria-hidden="true">esc</kbd>
    </div>
    <p class="palette__label" id="palette-label">おすすめ</p>
    <div class="palette__scroll">
      <!-- 選んでいる行の地。1 枚だけを行の後ろで動かす -->
      <span class="palette__highlight" aria-hidden="true"></span>
      <ul class="palette__list" id="palette-list" role="listbox" aria-labelledby="palette-label">
        <li class="palette__option" id="cmd-template" role="option"><span class="palette__name">テンプレートから作成</span><kbd aria-hidden="true">C</kbd></li>
        <li class="palette__option" id="cmd-search" role="option"><span class="palette__name">プロジェクトを検索</span><kbd aria-hidden="true">/</kbd></li>
        <li class="palette__option" id="cmd-upload" role="option"><span class="palette__name">ファイルをアップロード</span><kbd aria-hidden="true">⌘U</kbd></li>
        <li class="palette__option" id="cmd-profile" role="option"><span class="palette__name">プロフィールを編集</span><kbd aria-hidden="true">GP</kbd></li>
        <li class="palette__option" id="cmd-theme" role="option"><span class="palette__name">ダークモードに切り替え</span><kbd aria-hidden="true">⌘⇧L</kbd></li>
        <li class="palette__option" id="cmd-settings" role="option"><span class="palette__name">プロジェクトの設定</span><kbd aria-hidden="true">GS</kbd></li>
        <li class="palette__option" id="cmd-help" role="option"><span class="palette__name">ヘルプを開く</span><kbd aria-hidden="true">?</kbd></li>
        <li class="palette__option" id="cmd-link" role="option"><span class="palette__name">プロジェクトのリンクをコピー</span><kbd aria-hidden="true">⌘⇧C</kbd></li>
        <li class="palette__option" id="cmd-logout" role="option"><span class="palette__name">ログアウト</span></li>
      </ul>
    </div>
    <p class="palette__empty" hidden>該当するコマンドがありません</p>
    <p class="palette__foot" aria-hidden="true">
      <span><kbd>↑</kbd><kbd>↓</kbd>移動</span>
      <span><kbd>↵</kbd>開く</span>
      <span><kbd>esc</kbd>閉じる</span>
    </p>
    <!-- 候補の数を読み上げに知らせる -->
    <p class="palette__status" role="status"></p>
  </div>
</dialog>

CSS
.palette-open,
.palette {
  --ink: #262626;                 /* 文字 */
  --surface: #ffffff;             /* パレットの面 */
  --ground: #f2f1ed;              /* 選んでいる行の地・下の帯 */
  --pressed: #e2e0da;             /* 押し込んだ行の地 */
  --mute: #6b6b6b;                /* 補足の文字 */
  --line: rgba(38, 38, 38, 0.12); /* 縁と区切り線 */
  --row: 48px;                    /* 行の高さ。絞り込みで縮める量も、ここから読む */
}

.palette-open kbd,
.palette kbd {
  display: inline-grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  box-sizing: border-box;
  padding: 0 5px;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--line);
  background-color: var(--surface);
  color: var(--mute);
  font: inherit;
  font-size: 11px;
  font-weight: 600;
  line-height: 1;
}

.palette-open {
  display: inline-flex;
  align-items: center;
  width: 216px;
  height: 40px;
  box-sizing: border-box;
  padding: 0 8px 0 12px;
  border: 0;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--line);
  background-color: var(--surface);
  color: var(--mute);
  font: inherit;
  font-size: 14px;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.palette-open span { flex: 1; text-align: left; }
.palette-open:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

/* 画面の上寄りに置く。余白は中の箱が持つ(暗幕を押したかどうかを「押した先が dialog 自身か」で見分けるため) */
.palette {
  width: min(640px, calc(100vw - 32px));
  margin: 12vh auto auto;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink);
}

/* 暗幕。開くときは @starting-style から、閉じるときは is-closing でパレットと一緒に薄める */
.palette::backdrop {
  opacity: 0;
  background-color: rgba(17, 17, 17, 0.46);
  transition: opacity 0.25s ease-out, display 0.25s allow-discrete, overlay 0.25s allow-discrete;
}

.palette[open]::backdrop { opacity: 1; }
@starting-style { .palette[open]::backdrop { opacity: 0; } }
.palette.is-closing::backdrop { opacity: 0; }

.palette__field { display: flex; align-items: center; gap: 12px; height: 64px; padding: 0 24px; border-bottom: 1px solid var(--line); }
.palette__lens { flex: none; width: 20px; height: 20px; fill: none; stroke: var(--mute); stroke-width: 2.4; stroke-linecap: round; }
/* 入力中の印は、点滅するキャレットに任せる(パレットの中で焦点を受けるのはここだけ) */
.palette__input { flex: 1; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: none; color: inherit; font: inherit; font-size: 18px; letter-spacing: 0.04em; }
.palette__input::placeholder { color: #898989; }
.palette__label { margin: 0; padding: 12px 24px 8px; color: var(--mute); font-size: 12px; font-weight: 700; line-height: 1; letter-spacing: 0.06em; }

/* 行が多いときは、この中だけを縦にスクロールする */
.palette__scroll { position: relative; max-height: min(432px, 60vh); margin: 0 8px 8px; overflow-y: auto; }

.palette__highlight { position: absolute; left: 0; right: 0; top: 0; height: var(--row); border-radius: 4px; background-color: var(--ground); }
.palette__highlight::before { content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 2px; background-color: var(--ink); }
.palette__highlight.is-pressed { background-color: var(--pressed); }

/* 行はハイライトより手前に描く。高さを 0 まで縮めるので、はみ出しを切る */
.palette__list { position: relative; margin: 0; padding: 0; list-style: none; }
.palette__option { display: flex; align-items: center; gap: 12px; height: var(--row); box-sizing: border-box; padding: 0 16px; overflow: hidden; font-size: 15px; letter-spacing: 0.04em; cursor: pointer; }
.palette__name { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.palette__name mark { background: none; color: inherit; font-weight: 700; }
.palette__empty { margin: 0; padding: 4px 24px 24px; color: var(--mute); font-size: 14px; }
.palette__foot { display: flex; align-items: center; gap: 24px; height: 48px; margin: 0; padding: 0 24px; border-top: 1px solid var(--line); background-color: var(--ground); color: var(--mute); font-size: 12px; }
.palette__foot span { display: inline-flex; align-items: center; gap: 4px; }
.palette__status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* 指で使う狭い画面では、キーの案内を出さない */
@media (max-width: 560px) { .palette__foot { display: none; } }
@media (prefers-reduced-motion: reduce) { .palette::backdrop { transition-duration: 0.01s; } }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で切り替える
const mark = (text) => Object.assign(document.createElement("mark"), { textContent: text });

document.querySelectorAll(".palette").forEach((dialog) => {
  const opener = document.querySelector(`[aria-controls="${dialog.id}"]`);
  const input = dialog.querySelector(".palette__input");
  const highlight = dialog.querySelector(".palette__highlight");
  const empty = dialog.querySelector(".palette__empty");
  const status = dialog.querySelector(".palette__status");
  const items = [...dialog.querySelectorAll(".palette__name")].map((name) => ({ el: name.closest("[role=option]"), name, label: name.textContent }));
  const row = () => parseFloat(getComputedStyle(dialog).getPropertyValue("--row"));
  let hits = items;
  let selected = 0;

  // 開く: 上から少し降りる(暗幕は CSS)。隠すのは opacity だけ(autoAlpha だと、開いた瞬間に入力欄が焦点を受けられない)
  const open = gsap
    .timeline({ paused: true, onReverseComplete: () => dialog.close() }) // 閉じる動きが終わってから閉じる
    .fromTo(dialog, { opacity: 0 }, { opacity: 1, duration: 0.12, ease: "power1.out" }, 0)
    .fromTo(dialog, { y: -12 }, { y: 0, duration: 0.42, ease: "power3.out" }, 0);

  // ハイライトは「詰めきった後の並びで何行目か × 行の高さ」へ動かす(詰まる途中の行の位置を追うと、上下に揺れる)
  function select(index, instant) {
    selected = gsap.utils.clamp(0, Math.max(hits.length - 1, 0), index);
    items.forEach((item) => item.el.setAttribute("aria-selected", String(item === hits[selected])));
    input.setAttribute("aria-activedescendant", hits[selected]?.el.id ?? "");
    gsap.to(highlight, { y: selected * row(), autoAlpha: hits.length ? 1 : 0, duration: instant ? 0 : 0.24 / SPEED, ease: "power3.out", overwrite: "auto" });
    hits[selected]?.el.scrollIntoView({ block: "nearest" });
  }

  // 合わない行の高さを 0 へ縮めて、残りを詰める。一致した部分は <mark> で太くする
  function filter(instant) {
    const q = input.value.trim().toLowerCase();
    hits = items.filter((item) => item.label.toLowerCase().includes(q));
    items.forEach((item) => {
      const hit = hits.includes(item);
      gsap.to(item.el, { height: hit ? row() : 0, opacity: hit ? 1 : 0, duration: instant ? 0 : 0.3 / SPEED, ease: "power2.inOut", overwrite: "auto" });
      item.el.setAttribute("aria-hidden", String(!hit));
      const at = q ? item.label.toLowerCase().indexOf(q) : -1;
      if (at < 0) item.name.textContent = item.label;
      else item.name.replaceChildren(item.label.slice(0, at), mark(item.label.slice(at, at + q.length)), item.label.slice(at + q.length));
    });
    empty.hidden = hits.length > 0;
    status.textContent = hits.length ? `${hits.length}件の候補` : "該当するコマンドがありません";
    select(0, instant); // 打つたびに先頭の候補を選ぶ
  }

  function show() {
    input.value = "";
    filter(true);
    highlight.classList.remove("is-pressed");
    dialog.classList.remove("is-closing");
    dialog.showModal(); // 焦点は入力欄へ移り、背面は操作できなくなる。閉じると、開く前の場所へ焦点が戻る
    open.timeScale(SPEED).play(0);
  }

  function hide() {
    if (!dialog.open || dialog.classList.contains("is-closing")) return;
    dialog.classList.add("is-closing"); // 暗幕もパレットと一緒に薄める
    open.timeScale(SPEED * 1.6).reverse();
  }

  // 選んだ行を押し込んで見せてから閉じる(案件では、ここで item のコマンドを実行する)
  function run(item) {
    highlight.classList.add("is-pressed");
    gsap.delayedCall(0.12 / SPEED, hide);
  }

  opener.addEventListener("click", show);
  // ⌘K(Windows は Ctrl+K)で開き、もう一度押すと閉じる
  document.addEventListener("keydown", (event) => {
    if (event.isComposing || !(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") return;
    event.preventDefault();
    dialog.open ? hide() : show();
  });
  input.addEventListener("input", () => filter());
  input.addEventListener("keydown", (event) => {
    if (event.isComposing) return; // かな漢字変換を確定する Enter や、変換候補を選ぶ矢印では動かさない
    const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
    if (step) select(selected + step);
    else if (event.key === "Enter" && hits[selected]) run(hits[selected]);
    else return;
    event.preventDefault(); // 矢印で入力欄のキャレットを動かさない
  });
  // マウスでは、重ねた行を選び、押した行を実行する
  items.forEach((item) => {
    item.el.addEventListener("pointermove", () => hits.includes(item) && hits.indexOf(item) !== selected && select(hits.indexOf(item)));
    item.el.addEventListener("click", () => hits.includes(item) && run(item));
  });
  // Esc は既定だと即座に閉じるので、いったん止めて同じ動きで閉じる
  dialog.addEventListener("cancel", (event) => {
    event.preventDefault();
    hide();
  });
  // 余白は中の箱が持っているので、dialog 自身を押したときだけ「暗幕を押した」になる
  dialog.addEventListener("click", (event) => event.target === dialog && hide());
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。