デモ
デモを読み込めませんでした。
仕様
- 用途
- オーバーレイ
- 動作
- 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 の横断検索や、ショートカットの一覧に向いています。
コード
<!-- ⌘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>
.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; } }
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());
});