Next.jsのUXを格上げ:『10億ドル規模SaaS』基準のCommand Palette(Ctrl+K)を構築する

Development tutorial - IT technology blog
Development tutorial - IT technology blog

なぜアプリケーションにCommand Paletteが必要なのか?

プロフェッショナルなユーザーは、キーボードから手を離してマウスを動かす操作を嫌う傾向があります。VS Code、Slack、Linearなどを使ったことがあれば、Ctrl + K(またはCmd + K)がもはや「ゴールデンスタンダード」になっていることに気づくでしょう。これは、ユーザーが瞬時にドキュメントを検索したり、ページを切り替えたり、設定を変更したりするための究極のショートカットです。

Command Paletteは、複雑なSaaSアプリケーションのUXを大幅に向上させます。「パワーユーザー」は、通常のメニュークリックよりも2〜3倍速く操作できるようになります。しかし、矢印キーの操作、曖昧検索(fuzzy search)への対応、そしてA11y(アクセシビリティ)基準を満たすコマンドバーを自前で構築するのは、決して簡単なことではありません。

よく使われる3つのアプローチの比較

これまでの実装経験に基づき、以下の3つの手法のメリットとデメリットをまとめました。

1. 完全な自作(Custom Implementation)

useStateで開閉状態を管理し、useEffectでキーイベントをキャッチする方法です。

  • メリット: ロジックを完全に制御でき、バンドルサイズを最小限に抑えられる。
  • デメリット: アクセシビリティ基準(WAI-ARIA)への対応が非常に困難。矢印キーによるフォーカス管理などでバグが発生しやすい。

2. KBARライブラリ

KBARはReact向けの強力なソリューションで、階層化されたアクション(nested actions)を標準でサポートしています。

  • メリット: 導入が速く、基本的なUIが用意されている。
  • デメリット: 付属のUIを独自のデザインシステムに合わせてカスタマイズするのが難しい場合がある。Headlessなソリューションに比べてバンドルサイズが重い。

3. CMDKライブラリ(VercelやLinearが採用)

これはキー操作と検索のロジックに特化したHeadless UIライブラリです。Raycastの検索バーの背後にある「武器」としても知られています。

  • メリット: 非常に軽量(gzip時で約5kb)、超高速な曖昧検索、厳格なA11y準拠。
  • デメリット: CSSを完全に自前で書く必要がある。

アドバイス: キーボード操作のインタラクション部分で「車輪の再発明」をしようとしないでください。現在のモダンなNext.jsプロジェクトには、CMDK + Tailwind CSSの組み合わせが最も効率的で最適なソリューションです。

CMDKによるCommand Paletteの実装

環境構築

まず、CMDKライブラリとアイコン用のLucide Reactをインストールします。

npm install cmdk lucide-react

Command Menuコンポーネントの構築

以下は components/CommandMenu.tsx の構成例です。メニューが必要な時にだけ表示されるよう、ロジックを最適化しています。

"use client"

import React, { useEffect, useState } from 'react'
import { Command } from 'cmdk'
import { Search, User, Settings, LayoutDashboard, LogOut } from 'lucide-react'
import { useRouter } from 'next/navigation'

export const CommandMenu = () => {
  const [open, setOpen] = useState(false)
  const router = useRouter()

  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)
  }, [])

  const runCommand = (command: () => void) => {
    setOpen(false)
    command()
  }

  return (
    <Command.Dialog 
      open={open} 
      onOpenChange={setOpen} 
      label="Global Command Menu"
      className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/50 backdrop-blur-sm"
    >
      <div className="bg-white dark:bg-zinc-900 w-full max-w-2xl rounded-xl border border-zinc-200 dark:border-zinc-800 shadow-2xl overflow-hidden">
        <div className="flex items-center border-b border-zinc-200 dark:border-zinc-800 px-3">
          <Search className="w-5 h-5 text-zinc-400" />
          <Command.Input 
            placeholder="コマンドやページを検索..." 
            className="w-full p-4 bg-transparent outline-none text-zinc-800 dark:text-zinc-100 placeholder:text-zinc-400"
          />
        </div>

        <Command.List className="max-h-[400px] overflow-y-auto p-2">
          <Command.Empty className="p-4 text-center text-sm text-zinc-500">結果が見つかりませんでした。</Command.Empty>

          <Command.Group heading="ナビゲーション" className="px-2 py-3 text-xs font-medium text-zinc-500 uppercase">
            <CommandItem onSelect={() => runCommand(() => router.push('/dashboard'))}>
              <LayoutDashboard className="mr-2 h-4 w-4" /> ダッシュボード
            </CommandItem>
            <CommandItem onSelect={() => runCommand(() => router.push('/profile'))}>
              <User className="mr-2 h-4 w-4" /> プロフィール
            </CommandItem>
          </Command.Group>

          <Command.Separator className="h-px bg-zinc-200 dark:bg-zinc-800 my-2" />

          <Command.Group heading="システム" className="px-2 py-3 text-xs font-medium text-zinc-500 uppercase">
            <CommandItem onSelect={() => runCommand(() => router.push('/settings'))}>
              <Settings className="mr-2 h-4 w-4" /> 設定
            </CommandItem>
          </Command.Group>
        </Command.List>
      </div>
    </Command.Dialog>
  )
}

const CommandItem = ({ children, onSelect }: { children: React.ReactNode, onSelect: () => void }) => (
  <Command.Item 
    onSelect={onSelect}
    className="flex items-center px-3 py-2 rounded-lg cursor-pointer text-sm text-zinc-700 dark:text-zinc-300 aria-selected:bg-indigo-600 aria-selected:text-white transition-colors"
  >
    {children}
  </Command.Item>
)

Root Layoutへの統合

アプリ全体でメニューを有効にするために、layout.tsx ファイルでコンポーネントを呼び出します。

import { CommandMenu } from '@/components/CommandMenu'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        {children}
        <CommandMenu />
      </body>
    </html>
  )
}

実践的な最適化のヒント

1. フォームとの衝突を防ぐ

よくあるミスは、ユーザーが input 要素内で ‘K’ キーを入力している時にCommand Paletteが誤作動してしまうことです。CMDKはこの問題を内部で適切に処理していますが、ロジックを自作する場合は、ユーザーが入力作業を邪魔されないよう event.target をチェックすることを忘れないでください。

2. 表示結果数の制限

数千ものアイテムを一度にDOMにレンダリングしないでください。60fpsの快適なレスポンスを維持するために、表示するのは最も関連性の高い結果を最大10〜15個程度に留めるのがベストです。データ量が多い場合は、サーバーサイドでのフィルタリングや、useMemo によるクライアント側のパフォーマンス最適化を検討してください。

3. アニメーションの追加

Framer Motion を使用して、控えめな scalefade-in エフェクトを追加してみましょう。こうした細かなディテールが、アプリケーションに滑らかさと高級感を与えます。

結論

Command Paletteは単なる検索バーではありません。それはユーザーの作業効率を最大化するためのコントロールセンターです。Next.jsとCMDKを活用すれば、わずか数時間でこの機能を実装でき、得られるUXの価値は計り知れません。ぜひあなたのプロジェクトでも試してみてください!

Share: