N-010 Navigation medium

How to create a command palette コマンドパレットの作り方

Keyboard-first command palette that opens with Ctrl or Cmd + K and filters actions in a modal list. Ctrl / Cmd + K で開き、モーダル内のアクションを絞り込めるキーボード優先のコマンドパレット。

ライブデモ Live Demo

DevSnips Console Navigate and run actions from one keyboard surface.
Selected command appears here.

概要・用途・特徴Overview, Usage & Features

何ができるかWhat it does

A modal command palette that opens from a visible trigger or the Ctrl/Cmd + K shortcut, filters available commands, and lets users execute an action without leaving the keyboard.

表示トリガーまたは Ctrl/Cmd + K で開き、利用可能なコマンドを絞り込み、キーボードだけでアクションを実行できるモーダル型コマンドパレット。

どこで使うかWhere to use

Use it in dashboards, documentation sites, admin panels, developer tools, and AI products where users need fast access to navigation and frequent actions.

ダッシュボード、ドキュメントサイト、管理画面、開発者ツール、AIプロダクトなど、ユーザーがナビゲーションや頻出アクションへ素早くアクセスしたい画面で使います。

特徴Key features

Visible trigger with shortcut hint. Global Ctrl/Cmd + K listener. Search input with instant filtering. Arrow Up/Down and Enter support. Escape and backdrop click close the palette. Empty state for no results.

ショートカットヒント付きの表示トリガー。グローバルな Ctrl/Cmd + K リスナー。即時フィルタリングする検索入力。上下矢印とEnter操作。Escapeと背景クリックで閉じる動作。結果なしの空状態。

調整可能パラメータ Adjustable Parameters

Parameter Default Description
--command-panel-width640pxMaximum width of the command palette panel.
--command-backdroprgba(15, 23, 42, 0.42)Backdrop color behind the modal.
--command-active-bg#eef4ffBackground color for the active command item.
shortcutCtrl/Cmd + KGlobal keyboard shortcut that opens the palette.

実装コード Implementation Code

<button class="command-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
  <span>Search commands</span>
  <kbd>Ctrl K</kbd>
</button>

<div class="command-shell" role="dialog" aria-modal="true" aria-label="Command palette" hidden>
  <div class="command-panel">
    <input class="command-input" type="search" placeholder="Type a command..." role="combobox" aria-expanded="true" aria-controls="command-list" aria-activedescendant="command-0" />
    <div id="command-list" class="command-list" role="listbox"></div>
  </div>
</div>

<script>
const commands = [
  { title: 'Open dashboard', group: 'Navigation', hint: 'G D' },
  { title: 'Create snippet', group: 'Actions', hint: 'N' },
  { title: 'View deploy checklist', group: 'Docs', hint: 'D C' }
];

// Bind trigger, filtering, arrow keys, Enter, Escape, and backdrop close here.
</script>
.command-trigger {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid #d8dee9;
  border-radius: 10px;
  background: #f8fafc;
  color: #172033;
  font: inherit;
  cursor: pointer;
}

.command-trigger kbd {
  border: 1px solid #c8d1df;
  border-radius: 6px;
  padding: 2px 7px;
  background: #ffffff;
  color: #64748b;
  font-size: 12px;
}

.command-shell {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: start center;
  padding: 12vh 16px 16px;
  background: rgba(15, 23, 42, 0.42);
  z-index: 1000;
}

.command-shell[hidden] {
  display: none;
}

.command-panel {
  width: min(640px, 100%);
  overflow: hidden;
  border: 1px solid rgba(148, 163, 184, 0.36);
  border-radius: 14px;
  background: #ffffff;
  box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
}

.command-input {
  width: 100%;
  min-height: 58px;
  border: 0;
  border-bottom: 1px solid #e2e8f0;
  padding: 0 18px;
  font: inherit;
  outline: none;
}

.command-list {
  max-height: 360px;
  overflow-y: auto;
  padding: 8px;
}

.command-item {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px 14px;
  width: 100%;
  padding: 12px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #172033;
  text-align: left;
  cursor: pointer;
}

.command-item.is-active,
.command-item:hover {
  background: #eef4ff;
}

.command-item small {
  color: #64748b;
}

.command-empty {
  padding: 22px 12px;
  color: #64748b;
  text-align: center;
}
import { useEffect, useMemo, useRef, useState } from 'react';
import './N-010.css';

const DEFAULT_COMMANDS = [
  { id: 'dashboard', title: 'Open dashboard', group: 'Navigation', hint: 'G D' },
  { id: 'snippet', title: 'Create snippet', group: 'Actions', hint: 'N' },
  { id: 'checklist', title: 'View deploy checklist', group: 'Docs', hint: 'D C' },
  { id: 'theme', title: 'Toggle theme', group: 'Preferences', hint: 'T' }
];

export default function CommandPalette({ commands = DEFAULT_COMMANDS, onSelect }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [activeIndex, setActiveIndex] = useState(0);
  const triggerRef = useRef(null);
  const inputRef = useRef(null);

  const filtered = useMemo(() => {
    const needle = query.trim().toLowerCase();
    if (!needle) return commands;
    return commands.filter((command) =>
      [command.title, command.group, command.hint].some((value) =>
        String(value || '').toLowerCase().includes(needle)
      )
    );
  }, [commands, query]);

  useEffect(() => {
    const onKeyDown = (event) => {
      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
        event.preventDefault();
        setOpen(true);
      }
      if (event.key === 'Escape') setOpen(false);
    };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, []);

  useEffect(() => {
    if (open) inputRef.current?.focus();
    else triggerRef.current?.focus();
  }, [open]);

  useEffect(() => setActiveIndex(0), [query]);

  const choose = (command) => {
    if (!command) return;
    onSelect?.(command);
    setOpen(false);
    setQuery('');
  };

  const onInputKeyDown = (event) => {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      setActiveIndex((index) => Math.min(index + 1, filtered.length - 1));
    }
    if (event.key === 'ArrowUp') {
      event.preventDefault();
      setActiveIndex((index) => Math.max(index - 1, 0));
    }
    if (event.key === 'Enter') {
      event.preventDefault();
      choose(filtered[activeIndex]);
    }
  };

  return (
    <>
      <button
        ref={triggerRef}
        className="command-trigger"
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        onClick={() => setOpen(true)}
      >
        <span>Search commands</span>
        <kbd>Ctrl K</kbd>
      </button>

      {open && (
        <div className="command-shell" role="dialog" aria-modal="true" aria-label="Command palette" onMouseDown={() => setOpen(false)}>
          <div className="command-panel" onMouseDown={(event) => event.stopPropagation()}>
            <input
              ref={inputRef}
              className="command-input"
              type="search"
              value={query}
              placeholder="Type a command..."
              role="combobox"
              aria-expanded="true"
              aria-controls="command-list"
              aria-activedescendant={filtered[activeIndex] ? `command-${filtered[activeIndex].id}` : undefined}
              onChange={(event) => setQuery(event.target.value)}
              onKeyDown={onInputKeyDown}
            />
            <div id="command-list" className="command-list" role="listbox">
              {filtered.map((command, index) => (
                <button
                  id={`command-${command.id}`}
                  key={command.id}
                  type="button"
                  role="option"
                  aria-selected={index === activeIndex}
                  className={"command-item" + (index === activeIndex ? ' is-active' : '')}
                  onMouseEnter={() => setActiveIndex(index)}
                  onClick={() => choose(command)}
                >
                  <span>{command.title}</span>
                  <kbd>{command.hint}</kbd>
                  <small>{command.group}</small>
                </button>
              ))}
              {filtered.length === 0 && <p className="command-empty">No commands found.</p>}
            </div>
          </div>
        </div>
      )}
    </>
  );
}
.command-trigger {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  padding: 0 14px;
  border: 1px solid #d8dee9;
  border-radius: 10px;
  background: #f8fafc;
  color: #172033;
  font: inherit;
  cursor: pointer;
}

.command-trigger kbd,
.command-item kbd {
  border: 1px solid #c8d1df;
  border-radius: 6px;
  padding: 2px 7px;
  background: #fff;
  color: #64748b;
  font-size: 12px;
}

.command-shell {
  position: fixed;
  inset: 0;
  display: grid;
  place-items: start center;
  padding: 12vh 16px 16px;
  background: rgba(15, 23, 42, 0.42);
  z-index: 1000;
}

.command-panel {
  width: min(640px, 100%);
  overflow: hidden;
  border: 1px solid rgba(148, 163, 184, 0.36);
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
}

.command-input {
  width: 100%;
  min-height: 58px;
  border: 0;
  border-bottom: 1px solid #e2e8f0;
  padding: 0 18px;
  font: inherit;
  outline: none;
}

.command-list {
  max-height: 360px;
  overflow-y: auto;
  padding: 8px;
}

.command-item {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px 14px;
  width: 100%;
  padding: 12px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #172033;
  text-align: left;
  cursor: pointer;
}

.command-item.is-active,
.command-item:hover {
  background: #eef4ff;
}

.command-item small {
  color: #64748b;
}

.command-empty {
  padding: 22px 12px;
  color: #64748b;
  text-align: center;
}

仕組みとカスタマイズHow It Works & Customization

仕組みHow it works

A button toggles the open state and a keydown listener opens the palette when Ctrl/Cmd + K is pressed. The input filters a command array by title, description, and group. Arrow keys update the active index, while Enter selects the highlighted command. aria-activedescendant keeps the active option connected to the input.

ボタンで開閉状態を切り替え、keydownリスナーが Ctrl/Cmd + K を検知してパレットを開きます。入力値でタイトル、説明、グループを含むコマンド配列を絞り込みます。矢印キーでアクティブ項目を更新し、Enterで選択します。aria-activedescendant により入力欄とアクティブな候補を関連付けます。

カスタマイズ方法Customization

Group commands by product area, add icons or keyboard hints, connect each command to router navigation, and persist recent commands above the filtered list. For large datasets, debounce the filter or move the search to a worker.

プロダクト領域ごとにコマンドをグループ化し、アイコンやショートカットヒントを追加し、各コマンドをルーター遷移につなげられます。最近使ったコマンドを上部に出す拡張も有効です。大量データではフィルターをデバウンスするかWorkerへ移してください。

注意点Caveats

Return focus to the trigger when the palette closes. Do not trap users if the palette opens from a text field; ignore Ctrl/Cmd + K when an editable target is already handling shortcuts. Keep command labels short enough to scan quickly.

パレットを閉じたらトリガーへフォーカスを戻してください。入力欄など編集可能な要素がショートカットを処理している場合は Ctrl/Cmd + K を奪わないようにします。コマンド名は素早くスキャンできる長さに保ってください。

よくある質問 Frequently Asked Questions

How should keyboard navigation work in a command palette? コマンドパレットのキーボード操作はどう設計すべきですか?

Ctrl/Cmd + K should open the palette, Escape should close it, Arrow Up/Down should move the active option, and Enter should execute the active command. Keep focus on the input and expose the active option through aria-activedescendant.

Ctrl/Cmd + K で開き、Escapeで閉じ、上下矢印でアクティブ候補を移動し、Enterで実行するのが基本です。フォーカスは入力欄に置いたまま、aria-activedescendant でアクティブ候補を伝えます。

Should the palette filter locally or call an API? ローカルで絞り込むべきですか、それともAPIを呼ぶべきですか?

Small command lists should filter locally for instant feedback. Use an API or worker only when the searchable dataset is large, remote, permission-dependent, or expensive to compute.

小さなコマンド一覧なら即時反応のためローカルで絞り込みます。検索対象が大きい、リモートにある、権限依存、計算コストが高い場合だけAPIやWorkerを使います。

How do I avoid shortcut conflicts? ショートカットの衝突を避けるには?

Skip the global shortcut when the active element is a textarea, contenteditable region, or a custom editor that already owns Ctrl/Cmd + K. Let users discover the shortcut through a visible kbd hint on the trigger.

アクティブ要素が textarea、contenteditable、または Ctrl/Cmd + K を既に使うカスタムエディタの場合は、グローバルショートカットを無視します。トリガー上の kbd 表示でショートカットを発見できるようにします。

AIへの指示テンプレート AI Prompt Template

以下をAIに貼り付けると実装を依頼できます。 Paste the following into your AI assistant to request implementation.