T-CREATOR

shadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応

shadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応

モダンな Web アプリケーションで欠かせなくなった Command Palette(コマンドパレット)。VS Code や GitHub など、多くのアプリケーションが採用しており、ユーザーは Ctrl+KCmd+K を押すだけで、すべての機能に素早くアクセスできます。この記事では、shadcn/ui を使って、検索・履歴・キーボードショートカット対応の本格的な Command Palette を実装する方法を解説します。

初心者の方でも安心して実装できるよう、段階的に進めていきますので、ぜひ最後までお付き合いください。

背景

Command Palette が必要とされる理由

現代の Web アプリケーションは多機能化が進み、ユーザーが利用できる機能も増えています。しかし、機能が増えるほど「どこにその機能があるのか」を探すのが難しくなってしまいます。

Command Palette は、この課題を解決する UI パターンの一つです。ユーザーはキーボードショートカットでパレットを開き、実行したい操作を検索できるため、マウス操作よりも圧倒的に効率的なんです。

shadcn/ui の魅力

shadcn/ui は、コピー&ペーストで使えるコンポーネントライブラリとして人気を集めています。npm パッケージとしてインストールするのではなく、必要なコンポーネントだけをプロジェクトにコピーして使うため、カスタマイズが容易で、バンドルサイズも最小限に抑えられます。

Command Palette の実装には、shadcn/ui の Command コンポーネントと Dialog コンポーネントを組み合わせて使用します。

以下の図は、Command Palette がアプリケーションのどの層に位置するかを示しています。

mermaidflowchart TB
  user["ユーザー"]
  ui["UI Layer<br/>(shadcn/ui コンポーネント)"]
  palette["Command Palette<br/>(Dialog + Command)"]
  action["アクション実行<br/>(ナビゲーション・設定など)"]

  user -->|"Cmd+K / Ctrl+K"| palette
  palette -.->|"コンポーネント"| ui
  palette -->|"コマンド選択"| action
  action -->|"フィードバック"| user

この図からわかるように、Command Palette はユーザーとアクション実行の間をつなぐ重要な役割を担っています。

実装する機能の全体像

本記事で実装する Command Palette には、以下の機能を含めます。

#機能説明
1キーボードショートカットCmd+K(Mac)/ Ctrl+K(Windows)で開閉
2リアルタイム検索入力に応じてコマンドを即座にフィルタリング
3コマンド履歴過去に実行したコマンドを記録・表示
4グループ化コマンドをカテゴリー別に整理
5キーボードナビゲーション↑↓ キーで選択、Enter で実行

課題

既存の実装方法の問題点

Command Palette を一から実装しようとすると、以下のような課題に直面します。

アクセシビリティ対応の複雑さ

キーボード操作やスクリーンリーダー対応など、アクセシビリティを考慮した実装は非常に複雑です。適切な ARIA 属性の設定、フォーカス管理、キーボードイベントのハンドリングなど、考慮すべき点が多岐にわたります。

状態管理の煩雑さ

Command Palette には、開閉状態、検索クエリ、選択中のアイテム、コマンド履歴など、複数の状態を管理する必要があります。これらを適切に管理しないと、バグの温床になってしまいます。

スタイリングの一貫性

アプリケーション全体のデザインシステムと一貫性を保ちながら、使いやすい UI を実装するのは簡単ではありません。

以下の図は、Command Palette 実装時に考慮すべき課題を整理したものです。

mermaidflowchart TD
  impl["Command Palette<br/>実装の課題"]

  a11y["アクセシビリティ"]
  state["状態管理"]
  style_["スタイリング"]
  perf["パフォーマンス"]

  a11y_detail["・ARIA 属性<br/>・フォーカス管理<br/>・キーボード操作"]
  state_detail["・開閉状態<br/>・検索クエリ<br/>・履歴管理"]
  style_detail["・デザイン一貫性<br/>・レスポンシブ<br/>・アニメーション"]
  perf_detail["・検索の高速化<br/>・レンダリング最適化<br/>・遅延読み込み"]

  impl --> a11y
  impl --> state
  impl --> style_
  impl --> perf

  a11y --> a11y_detail
  state --> state_detail
  style_ --> style_detail
  perf --> perf_detail

これらの課題をすべて自力で解決するのは、時間とコストがかかりすぎてしまいます。

shadcn/ui による課題解決

shadcn/ui を使えば、これらの課題の多くが解決されます。

  • アクセシビリティ: Radix UI をベースにしているため、標準で対応済み
  • 状態管理: React のフック API で簡潔に記述可能
  • スタイリング: Tailwind CSS による一貫したデザイン
  • パフォーマンス: cmdk ライブラリによる高速な検索

解決策

環境構築とパッケージインストール

まず、Next.js プロジェクトに shadcn/ui をセットアップします。すでに Next.js プロジェクトがある前提で進めますが、新規プロジェクトの場合は yarn create next-app から始めてください。

shadcn/ui の初期化

shadcn/ui CLI を使って、プロジェクトを初期化します。この CLI は、必要な設定ファイルと依存関係を自動的にセットアップしてくれます。

bashyarn add -D @shadcn/ui
yarn shadcn-ui init

初期化時に、以下の質問に答えます。

  • スタイル: Default(デフォルトのまま推奨)
  • ベースカラー: お好みで選択(Slate がおすすめ)
  • CSS 変数: Yes(カスタマイズしやすくなります)

必要なコンポーネントの追加

Command Palette に必要な commanddialog コンポーネントを追加しましょう。

bashyarn shadcn-ui add command dialog

このコマンドを実行すると、components​/​ui​/​command.tsxcomponents​/​ui​/​dialog.tsx がプロジェクトに追加されます。

基本的な Command Palette の実装

それでは、基本的な Command Palette を実装していきます。

コンポーネントの構造設計

Command Palette は、以下の階層で構成します。

  1. Dialog: モーダル表示を担当
  2. Command: コマンド検索と選択を担当
  3. CommandInput: 検索入力フィールド
  4. CommandList: コマンドのリスト表示
  5. CommandGroup: コマンドのグループ化
  6. CommandItem: 個別のコマンド

以下は、これらのコンポーネントの関係性を示した図です。

mermaidflowchart TB
  Dialog["Dialog<br/>(モーダル表示)"]
  Command["Command<br/>(検索・選択機能)"]
  Input["CommandInput<br/>(検索入力)"]
  List["CommandList<br/>(リスト表示)"]
  Group1["CommandGroup<br/>(ナビゲーション)"]
  Group2["CommandGroup<br/>(設定)"]
  Item1["CommandItem"]
  Item2["CommandItem"]
  Item3["CommandItem"]

  Dialog --> Command
  Command --> Input
  Command --> List
  List --> Group1
  List --> Group2
  Group1 --> Item1
  Group1 --> Item2
  Group2 --> Item3

この構造により、役割が明確に分離され、保守性の高いコードになります。

CommandPalette コンポーネントの作成

まず、基本的な CommandPalette コンポーネントを作成します。このコンポーネントは、Dialog と Command を組み合わせて、モーダル形式で表示します。

typescript'use client';

import * as React from 'react';
import {
  Dialog,
  DialogContent,
} from '@/components/ui/dialog';
import {
  Command,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
} from '@/components/ui/command';

次に、コンポーネントの基本的な型定義を行います。

typescript// コマンドアイテムの型定義
interface CommandItemType {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
  keywords?: string[]; // 検索用キーワード
}

// コマンドグループの型定義
interface CommandGroupType {
  heading: string;
  items: CommandItemType[];
}

// Props の型定義
interface CommandPaletteProps {
  groups: CommandGroupType[];
}

型定義により、TypeScript の補完機能が使えるようになり、開発効率が向上します。

開閉状態の管理

Command Palette の開閉状態を React の useState で管理します。また、useEffect を使ってキーボードショートカットを設定しましょう。

typescriptexport function CommandPalette({ groups }: CommandPaletteProps) {
  const [open, setOpen] = React.useState(false)

  // Cmd+K / Ctrl+K でパレットを開閉
  React.useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault()
        setOpen((open) => !open)
      }
    }

    document.addEventListener("keydown", down)
    return () => document.removeEventListener("keydown", down)
  }, [])

この実装により、ユーザーは Cmd+K(Mac)または Ctrl+K(Windows)を押すだけで、いつでも Command Palette を呼び出せます。

UI のレンダリング

Dialog と Command を組み合わせて、UI をレンダリングします。

typescript  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent className="overflow-hidden p-0">
        <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground">
          <CommandInput placeholder="コマンドを検索..." />
          <CommandList>
            <CommandEmpty>結果が見つかりませんでした。</CommandEmpty>

次に、各グループとアイテムをレンダリングします。

typescript            {groups.map((group) => (
              <CommandGroup key={group.heading} heading={group.heading}>
                {group.items.map((item) => (
                  <CommandItem
                    key={item.id}
                    onSelect={() => {
                      item.onSelect()
                      setOpen(false) // コマンド実行後に閉じる
                    }}
                    keywords={item.keywords}
                  >
                    {item.icon && (
                      <span className="mr-2">{item.icon}</span>
                    )}
                    <span>{item.label}</span>
                  </CommandItem>
                ))}
              </CommandGroup>
            ))}
          </CommandList>
        </Command>
      </DialogContent>
    </Dialog>
  )
}

これで、基本的な Command Palette が完成しました。

キーボードショートカットの拡張

より使いやすくするために、個別のコマンドにもキーボードショートカットを割り当てましょう。

ショートカット表示の追加

まず、コマンドアイテムの型定義に shortcut プロパティを追加します。

typescriptinterface CommandItemType {
  id: string;
  label: string;
  icon?: React.ReactNode;
  onSelect: () => void;
  keywords?: string[];
  shortcut?: string; // ショートカットキー(例: "⌘P", "Ctrl+P")
}

次に、CommandItem のレンダリング部分を更新します。

typescript<CommandItem
  key={item.id}
  onSelect={() => {
    item.onSelect();
    setOpen(false);
  }}
  keywords={item.keywords}
>
  <div className='flex items-center gap-2 flex-1'>
    {item.icon && <span className='mr-2'>{item.icon}</span>}
    <span>{item.label}</span>
  </div>
  {item.shortcut && (
    <kbd className='pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground opacity-100'>
      {item.shortcut}
    </kbd>
  )}
</CommandItem>

これで、各コマンドにショートカットキーが表示されるようになります。

グローバルショートカットの実装

個別のコマンドをグローバルショートカットで実行できるようにします。

typescriptexport function CommandPalette({ groups }: CommandPaletteProps) {
  const [open, setOpen] = React.useState(false)

  // すべてのコマンドを平坦化してマッピング
  const commandMap = React.useMemo(() => {
    const map = new Map<string, CommandItemType>()
    groups.forEach((group) => {
      group.items.forEach((item) => {
        if (item.shortcut) {
          map.set(item.shortcut, item)
        }
      })
    })
    return map
  }, [groups])

キーボードイベントリスナーを拡張して、個別のショートカットにも対応します。

typescriptReact.useEffect(() => {
  const down = (e: KeyboardEvent) => {
    // Cmd+K / Ctrl+K でパレットを開閉
    if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      setOpen((open) => !open);
      return;
    }

    // 個別のショートカット処理
    const modifier = e.metaKey
      ? '⌘'
      : e.ctrlKey
      ? 'Ctrl+'
      : '';
    const key = e.key.toUpperCase();
    const shortcut = `${modifier}${key}`;

    const command = commandMap.get(shortcut);
    if (command && (e.metaKey || e.ctrlKey)) {
      e.preventDefault();
      command.onSelect();
    }
  };

  document.addEventListener('keydown', down);
  return () =>
    document.removeEventListener('keydown', down);
}, [commandMap]);

この実装により、Command Palette を開かなくても、直接ショートカットでコマンドを実行できるようになります。

コマンド履歴機能の実装

ユーザーが過去に実行したコマンドを記録し、再度実行しやすくする機能を追加しましょう。

履歴管理用カスタムフックの作成

履歴の保存と読み込みを行う useCommandHistory フックを作成します。localStorage を使って、ブラウザを閉じても履歴が残るようにします。

typescriptimport * as React from "react"

interface HistoryItem {
  id: string
  label: string
  timestamp: number
}

const STORAGE_KEY = "command-palette-history"
const MAX_HISTORY = 5 // 最大履歴数

export function useCommandHistory() {
  const [history, setHistory] = React.useState<HistoryItem[]>([])

  // 初回マウント時に localStorage から履歴を読み込み
  React.useEffect(() => {
    const saved = localStorage.getItem(STORAGE_KEY)
    if (saved) {
      try {
        setHistory(JSON.parse(saved))
      } catch (e) {
        console.error("履歴の読み込みに失敗しました", e)
      }
    }
  }, [])

履歴に新しいアイテムを追加する関数を実装します。

typescriptconst addToHistory = React.useCallback(
  (item: Omit<HistoryItem, 'timestamp'>) => {
    setHistory((prev) => {
      // 同じコマンドが既に履歴にある場合は削除
      const filtered = prev.filter((h) => h.id !== item.id);

      // 新しいアイテムを先頭に追加
      const newHistory = [
        { ...item, timestamp: Date.now() },
        ...filtered,
      ].slice(0, MAX_HISTORY); // 最大数を超えた分は削除

      // localStorage に保存
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify(newHistory)
      );

      return newHistory;
    });
  },
  []
);

履歴をクリアする関数も追加します。

typescript  const clearHistory = React.useCallback(() => {
    setHistory([])
    localStorage.removeItem(STORAGE_KEY)
  }, [])

  return { history, addToHistory, clearHistory }
}

このフックにより、履歴の管理が簡潔に記述できます。

CommandPalette コンポーネントへの統合

作成した useCommandHistory フックを CommandPalette コンポーネントに統合します。

typescriptexport function CommandPalette({ groups }: CommandPaletteProps) {
  const [open, setOpen] = React.useState(false)
  const { history, addToHistory, clearHistory } = useCommandHistory()

  // コマンド実行時に履歴に追加
  const handleCommandSelect = (item: CommandItemType) => {
    item.onSelect()
    addToHistory({ id: item.id, label: item.label })
    setOpen(false)
  }

履歴グループを作成し、レンダリング部分に追加します。

typescript  // 履歴グループを作成
  const historyGroup: CommandGroupType | null = history.length > 0 ? {
    heading: "最近使ったコマンド",
    items: history.map((h) => {
      // 元のコマンドを検索
      const originalItem = groups
        .flatMap(g => g.items)
        .find(item => item.id === h.id)

      return originalItem || {
        id: h.id,
        label: h.label,
        onSelect: () => {}, // 元のコマンドが見つからない場合
      }
    }),
  } : null

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogContent className="overflow-hidden p-0">
        <Command>
          <CommandInput placeholder="コマンドを検索..." />
          <CommandList>
            <CommandEmpty>結果が見つかりませんでした。</CommandEmpty>

            {/* 履歴グループを最初に表示 */}
            {historyGroup && (
              <CommandGroup heading={historyGroup.heading}>
                {historyGroup.items.map((item) => (
                  <CommandItem
                    key={`history-${item.id}`}
                    onSelect={() => handleCommandSelect(item)}
                  >
                    {item.icon && <span className="mr-2">{item.icon}</span>}
                    <span>{item.label}</span>
                  </CommandItem>
                ))}
              </CommandGroup>
            )}

通常のコマンドグループもレンダリングします。

typescript            {/* 通常のコマンドグループ */}
            {groups.map((group) => (
              <CommandGroup key={group.heading} heading={group.heading}>
                {group.items.map((item) => (
                  <CommandItem
                    key={item.id}
                    onSelect={() => handleCommandSelect(item)}
                    keywords={item.keywords}
                  >
                    <div className="flex items-center gap-2 flex-1">
                      {item.icon && <span className="mr-2">{item.icon}</span>}
                      <span>{item.label}</span>
                    </div>
                    {item.shortcut && (
                      <kbd className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
                        {item.shortcut}
                      </kbd>
                    )}
                  </CommandItem>
                ))}
              </CommandGroup>
            ))}
          </CommandList>
        </Command>
      </DialogContent>
    </Dialog>
  )
}

これで、ユーザーが頻繁に使うコマンドにすぐアクセスできるようになりました。

具体例

実際のアプリケーションでの使用例

それでは、実際に CommandPalette コンポーネントを使ってみましょう。Next.js のページコンポーネントで使用する例を紹介します。

アイコンライブラリの追加

まず、コマンドにアイコンを表示するために、lucide-react をインストールします。

bashyarn add lucide-react

lucide-react は、shadcn/ui と相性の良いアイコンライブラリで、軽量かつモダンなアイコンが豊富に揃っています。

ページコンポーネントでの実装

Next.js の App Router を使った実装例です。

typescript'use client';

import { CommandPalette } from '@/components/command-palette';
import { useRouter } from 'next/navigation';
import {
  Home,
  Search,
  Settings,
  User,
  FileText,
  Mail,
  Bell,
} from 'lucide-react';

次に、コマンドグループを定義します。

typescriptexport default function HomePage() {
  const router = useRouter()

  // コマンドグループの定義
  const commandGroups = [
    {
      heading: "ナビゲーション",
      items: [
        {
          id: "home",
          label: "ホーム",
          icon: <Home className="w-4 h-4" />,
          onSelect: () => router.push("/"),
          shortcut: "⌘H",
          keywords: ["home", "ホーム", "トップ"],
        },
        {
          id: "search",
          label: "検索",
          icon: <Search className="w-4 h-4" />,
          onSelect: () => router.push("/search"),
          shortcut: "⌘S",
          keywords: ["search", "検索", "探す"],
        },
        {
          id: "profile",
          label: "プロフィール",
          icon: <User className="w-4 h-4" />,
          onSelect: () => router.push("/profile"),
          keywords: ["profile", "プロフィール", "ユーザー"],
        },
      ],
    },

設定や通知に関するコマンドグループも追加します。

typescript    {
      heading: "設定",
      items: [
        {
          id: "settings",
          label: "設定",
          icon: <Settings className="w-4 h-4" />,
          onSelect: () => router.push("/settings"),
          shortcut: "⌘,",
          keywords: ["settings", "設定", "環境設定"],
        },
        {
          id: "notifications",
          label: "通知",
          icon: <Bell className="w-4 h-4" />,
          onSelect: () => router.push("/notifications"),
          keywords: ["notifications", "通知", "お知らせ"],
        },
      ],
    },

アクション系のコマンドグループも定義します。

typescript    {
      heading: "アクション",
      items: [
        {
          id: "new-post",
          label: "新規投稿",
          icon: <FileText className="w-4 h-4" />,
          onSelect: () => router.push("/posts/new"),
          shortcut: "⌘N",
          keywords: ["new", "post", "新規", "投稿", "作成"],
        },
        {
          id: "send-message",
          label: "メッセージ送信",
          icon: <Mail className="w-4 h-4" />,
          onSelect: () => router.push("/messages/new"),
          shortcut: "⌘M",
          keywords: ["message", "メッセージ", "送信", "mail"],
        },
      ],
    },
  ]

  return (
    <div>
      <CommandPalette groups={commandGroups} />
      {/* ページの他のコンテンツ */}
      <main className="container mx-auto p-8">
        <h1 className="text-4xl font-bold mb-4">Welcome to My App</h1>
        <p className="text-muted-foreground">
          Cmd+K(または Ctrl+K)を押して、Command Palette を開いてください。
        </p>
      </main>
    </div>
  )
}

この実装により、ユーザーはキーボードショートカットで素早くアプリケーション内を移動できます。

動作フローの可視化

以下の図は、ユーザーが Command Palette を操作する際の流れを示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant KB as キーボード
  participant CP as CommandPalette
  participant History as 履歴管理
  participant Action as アクション実行

  User->>KB: Cmd+K 押下
  KB->>CP: パレット表示
  CP->>History: 履歴読み込み
  History-->>CP: 過去のコマンド
  CP->>User: UI 表示(履歴 + 全コマンド)

  User->>CP: 検索キーワード入力
  CP->>CP: フィルタリング
  CP->>User: 絞り込み結果表示

  User->>CP: コマンド選択(Enter)
  CP->>Action: onSelect 実行
  CP->>History: 履歴に追加
  History->>History: localStorage 保存
  CP->>User: パレット非表示
  Action->>User: アクション完了通知

このフローにより、ユーザーは直感的かつ効率的にコマンドを実行できます。

テーマ切り替えコマンドの追加例

shadcn/ui は next-themes と組み合わせて使うことが多いので、テーマ切り替えのコマンドも追加してみましょう。

まず、next-themes をインストールします。

bashyarn add next-themes

次に、テーマプロバイダーをセットアップします(layout.tsx)。

typescriptimport { ThemeProvider } from 'next-themes';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang='ja' suppressHydrationWarning>
      <body>
        <ThemeProvider
          attribute='class'
          defaultTheme='system'
          enableSystem
          disableTransitionOnChange
        >
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

ページコンポーネントにテーマ切り替えコマンドを追加します。

typescript"use client"

import { useTheme } from "next-themes"
import { Moon, Sun, Monitor } from "lucide-react"

export default function HomePage() {
  const router = useRouter()
  const { setTheme } = useTheme()

  const commandGroups = [
    // 既存のグループ...
    {
      heading: "外観",
      items: [
        {
          id: "theme-light",
          label: "ライトモード",
          icon: <Sun className="w-4 h-4" />,
          onSelect: () => setTheme("light"),
          keywords: ["light", "ライト", "明るい", "white"],
        },
        {
          id: "theme-dark",
          label: "ダークモード",
          icon: <Moon className="w-4 h-4" />,
          onSelect: () => setTheme("dark"),
          keywords: ["dark", "ダーク", "暗い", "black"],
        },
        {
          id: "theme-system",
          label: "システム設定に従う",
          icon: <Monitor className="w-4 h-4" />,
          onSelect: () => setTheme("system"),
          keywords: ["system", "システム", "自動"],
        },
      ],
    },
  ]

これで、Command Palette からテーマを素早く切り替えられます。

カスタムアクションの実装例

API 呼び出しやモーダル表示など、より複雑なアクションも実装できます。

typescriptimport { useState } from "react"
import { LogOut, Download, Trash2 } from "lucide-react"

export default function HomePage() {
  const [isLoading, setIsLoading] = useState(false)

  const commandGroups = [
    // 既存のグループ...
    {
      heading: "アカウント",
      items: [
        {
          id: "logout",
          label: "ログアウト",
          icon: <LogOut className="w-4 h-4" />,
          onSelect: async () => {
            if (confirm("ログアウトしますか?")) {
              setIsLoading(true)
              try {
                await fetch("/api/auth/logout", { method: "POST" })
                router.push("/login")
              } catch (error) {
                console.error("ログアウトに失敗しました", error)
              } finally {
                setIsLoading(false)
              }
            }
          },
          keywords: ["logout", "ログアウト", "サインアウト"],
        },
        {
          id: "export-data",
          label: "データをエクスポート",
          icon: <Download className="w-4 h-4" />,
          onSelect: async () => {
            setIsLoading(true)
            try {
              const response = await fetch("/api/export")
              const blob = await response.blob()
              const url = window.URL.createObjectURL(blob)
              const a = document.createElement("a")
              a.href = url
              a.download = "data.json"
              a.click()
            } catch (error) {
              console.error("エクスポートに失敗しました", error)
            } finally {
              setIsLoading(false)
            }
          },
          keywords: ["export", "エクスポート", "ダウンロード", "保存"],
        },
        {
          id: "delete-account",
          label: "アカウント削除",
          icon: <Trash2 className="w-4 h-4" />,
          onSelect: () => {
            if (confirm("本当にアカウントを削除しますか?この操作は取り消せません。")) {
              router.push("/account/delete")
            }
          },
          keywords: ["delete", "削除", "退会"],
        },
      ],
    },
  ]

このように、Command Palette は単なるナビゲーションだけでなく、様々なアクションのトリガーとして活用できます。

パフォーマンス最適化のポイント

Command Palette を実装する際のパフォーマンス最適化について、いくつかのポイントを紹介します。

useMemo によるコマンドグループの最適化

コマンドグループの定義を useMemo でメモ化することで、不要な再計算を防ぎます。

typescriptconst commandGroups = useMemo(
  () => [
    {
      heading: 'ナビゲーション',
      items: [
        // ... コマンドアイテム
      ],
    },
    // ... 他のグループ
  ],
  [router, setTheme]
); // 依存する値を明示

useCallback によるハンドラーの最適化

イベントハンドラーも useCallback でメモ化します。

typescriptconst handleCommandSelect = useCallback(
  (item: CommandItemType) => {
    item.onSelect();
    addToHistory({ id: item.id, label: item.label });
    setOpen(false);
  },
  [addToHistory]
);

大量のコマンドを扱う場合

コマンドが 100 個以上になる場合は、仮想スクロールの導入を検討しましょう。shadcn/ui の Command コンポーネントは、内部で cmdk を使用しており、ある程度の最適化は自動で行われますが、非常に大量のコマンドを扱う場合は追加の対策が必要です。

まとめ

本記事では、shadcn/ui を使った Command Palette の実装方法を詳しく解説しました。

実装した機能のおさらい

#機能メリット
1基本的な Command Paletteモーダル表示とコマンド検索の基盤
2キーボードショートカットCmd+K / Ctrl+K で即座にアクセス
3個別コマンドショートカットよく使う機能に直接アクセス
4コマンド履歴過去のコマンドをすぐに再実行
5グループ化カテゴリー別の整理で見つけやすく
6カスタムアクションナビゲーション以外にも活用可能

shadcn/ui を使うメリットの再確認

shadcn/ui を使うことで、以下のメリットが得られました。

  • アクセシビリティ: Radix UI ベースで標準対応
  • カスタマイズ性: コンポーネントがプロジェクト内にあるため自由に編集可能
  • 軽量: 必要なコンポーネントだけを追加
  • TypeScript サポート: 型安全な開発が可能
  • デザインの一貫性: Tailwind CSS による統一感

次のステップ

本記事で実装した Command Palette をさらに拡張する場合、以下の機能追加を検討してみてください。

検索アルゴリズムの改善

cmdk のデフォルト検索は優れていますが、ファジー検索や重み付けをカスタマイズすることで、より精度の高い検索が可能になります。

コマンドのネスト

階層構造のあるコマンド(例:設定 > 一般 > 言語)を実装することで、より多くのコマンドを整理して提供できます。

最近使ったファイル

VS Code のように、最近開いたファイルやページをコマンドとして表示する機能も便利です。

AI によるコマンド提案

ユーザーの行動パターンを学習し、次に実行しそうなコマンドを提案する機能も面白いでしょう。

最後に

Command Palette は、ユーザー体験を大きく向上させる UI パターンです。特にパワーユーザーにとっては、マウス操作よりもはるかに効率的にアプリケーションを操作できるため、導入する価値は非常に高いと言えます。

shadcn/ui を使えば、複雑な実装をせずに、高品質な Command Palette を短時間で構築できますので、ぜひあなたのプロジェクトにも取り入れてみてください。

きっと、ユーザーから「このアプリ、使いやすいね!」という声が聞こえてくるはずです。

関連リンク