Command Palette (CMD+K) Pattern

Frontend Engineering intermediate Building with AI 15 min read

Overview

The command palette pattern, commonly triggered by CMD+K (or Ctrl+K on Windows/Linux), provides users with a global command menu that surfaces navigation, actions, and contextual operations through a single searchable interface. Popularized by tools like VS Code, Figma, and Linear, this pattern has become a standard expectation in modern web applications where power users demand fast, keyboard-driven workflows.

At its core, a command palette combines a modal dialog with a filtered list of commands organized into logical groups. Users type to fuzzy-search through available actions, use arrow keys to navigate results, and press Enter to execute. This pattern dramatically reduces the time spent navigating deep menu hierarchies or hunting for features across complex interfaces.

In an internal component library, the command palette serves as the central hub for quick navigation between pages, triggering theme changes, accessing settings, and executing context-dependent actions based on the current view. The implementation leverages the cmdk library (by Paco Coursey) built on top of Radix UI primitives, ensuring accessibility and composability out of the box.

Core Concepts

cmdk Library

The cmdk library provides a headless, composable command menu component for React. It handles keyboard navigation, search filtering, item selection, and focus management while leaving styling entirely to the consumer. Built on Radix UI primitives, it follows the WAI-ARIA combobox pattern and supports nested pages, loading states, and custom filtering algorithms.

Fuzzy Search and Filtering

Command palettes use fuzzy matching to let users find commands without typing exact names. The cmdk library includes a built-in filter that scores items based on substring matches, but you can supply a custom filter function for more advanced ranking. This allows weighting recently used commands higher, boosting contextually relevant actions, or integrating external search indices.

Keyboard Navigation

Full keyboard accessibility is essential. Arrow keys move between items, Enter selects the highlighted item, Escape closes the palette, and the global CMD+K shortcut toggles it open. The component manages focus trapping within the dialog and restores focus to the previously active element on close.

Action Groups

Commands are organized into semantic groups such as Navigation, Actions, Settings, and Theme. Each group has a heading label and contains related items. Groups that have no matching items during a search are automatically hidden. This grouping helps users scan results quickly and provides organizational clarity even as the number of available commands grows.

Context-Dependent Commands

The palette dynamically adjusts its available commands based on the current application state. For example, when viewing a data table, commands for exporting, filtering, or sorting appear. When on a settings page, commands related to configuration take priority. This context sensitivity is achieved by registering and unregistering command providers as components mount and unmount.

How It Works

The command palette implementation consists of three layers: a global keyboard shortcut listener that toggles the dialog, a command registry that collects available actions from across the application, and the cmdk-powered UI that renders the searchable list inside a Radix Dialog overlay.

Global Shortcut Registration

A top-level effect listener captures the CMD+K keystroke and toggles the command palette open state. The listener uses event.metaKey on macOS and event.ctrlKey on other platforms to ensure cross-platform compatibility. It also prevents the browser’s default behavior (which in some browsers opens the address bar or search).

import { useEffect, useState } from "react";

export function useCommandPaletteShortcut() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // CMD+K on macOS, Ctrl+K on Windows/Linux
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((prev) => !prev);
      }
    };

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

  return { open, setOpen };
}

Command Registry Pattern

The command registry uses React context to allow any component in the tree to register commands that appear in the palette. When a component mounts, it registers its context-specific commands; when it unmounts, those commands are automatically removed. This decentralized approach keeps the palette in sync with the current UI state without requiring a centralized command list.

import { createContext, useContext, useCallback, useRef, useState } from "react";

interface Command {
  id: string;
  label: string;
  group: string;
  icon?: React.ReactNode;
  shortcut?: string;
  onSelect: () => void;
  keywords?: string[];
}

interface CommandRegistryContext {
  commands: Command[];
  register: (commands: Command[]) => () => void;
}

const CommandRegistryCtx = createContext<CommandRegistryContext | null>(null);

export function CommandRegistryProvider({ children }: { children: React.ReactNode }) {
  const [commands, setCommands] = useState<Command[]>([]);
  const idCounter = useRef(0);

  const register = useCallback((newCommands: Command[]) => {
    const batchId = ++idCounter.current;
    const tagged = newCommands.map((cmd) => ({
      ...cmd,
      _batchId: batchId,
    }));

    setCommands((prev) => [...prev, ...tagged] as Command[]);

    // Return unregister function
    return () => {
      setCommands((prev) =>
        prev.filter((cmd) => (cmd as any)._batchId !== batchId)
      );
    };
  }, []);

  return (
    <CommandRegistryCtx.Provider value={{ commands, register }}>
      {children}
    </CommandRegistryCtx.Provider>
  );
}

export function useCommandRegistry() {
  const ctx = useContext(CommandRegistryCtx);
  if (!ctx) throw new Error("useCommandRegistry must be used within CommandRegistryProvider");
  return ctx;
}

Rendering the Palette with cmdk

The Command component from cmdk renders the filtered, grouped list of commands. Each group is wrapped in Command.Group with a heading, and individual items are rendered with Command.Item. The entire palette is wrapped in a Radix Dialog for proper overlay behavior, focus trapping, and accessibility.

import { Command } from "cmdk";
import * as Dialog from "@radix-ui/react-dialog";
import { useCommandRegistry } from "./command-registry";
import { useCommandPaletteShortcut } from "./use-command-palette-shortcut";

export function CommandPalette() {
  const { open, setOpen } = useCommandPaletteShortcut();
  const { commands } = useCommandRegistry();

  // Group commands by their group property
  const groups = commands.reduce<Record<string, typeof commands>>(
    (acc, cmd) => {
      if (!acc[cmd.group]) acc[cmd.group] = [];
      acc[cmd.group].push(cmd);
      return acc;
    },
    {}
  );

  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Portal>
        <Dialog.Overlay className="cmdk-overlay" />
        <Dialog.Content className="cmdk-dialog" aria-label="Command palette">
          <Command label="Global Command Menu">
            <Command.Input
              placeholder="Type a command or search..."
              autoFocus
            />
            <Command.List>
              <Command.Empty>No results found.</Command.Empty>
              {Object.entries(groups).map(([groupName, items]) => (
                <Command.Group key={groupName} heading={groupName}>
                  {items.map((cmd) => (
                    <Command.Item
                      key={cmd.id}
                      value={[cmd.label, ...(cmd.keywords ?? [])].join(" ")}
                      onSelect={() => {
                        cmd.onSelect();
                        setOpen(false);
                      }}
                    >
                      {cmd.icon && <span className="cmdk-icon">{cmd.icon}</span>}
                      <span>{cmd.label}</span>
                      {cmd.shortcut && (
                        <kbd className="cmdk-shortcut">{cmd.shortcut}</kbd>
                      )}
                    </Command.Item>
                  ))}
                </Command.Group>
              ))}
            </Command.List>
          </Command>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Practical Applications

In the internal component library, the command palette acts as the primary power-user interface for navigating a component library and design system. Users press CMD+K to instantly jump between component documentation pages, toggle between light and dark themes, copy import snippets, or open the component playground for the currently viewed component.

The navigation group provides instant access to every component page, organized alphabetically. The actions group offers operations like “Copy component import”, “Open in Storybook”, and “View source on GitHub”. A settings group allows toggling theme, adjusting viewport size in the preview panel, and switching between code and visual views.

Context-dependent commands are especially valuable in the component library. When viewing a specific component page, the palette surfaces component-specific actions such as “Copy props interface”, “Jump to variants section”, or “Run accessibility audit”. These commands disappear when the user navigates to a different page, keeping the palette clean and relevant.

Implementation Patterns

Pattern 1: Basic cmdk Setup with shadcn/ui Command

The shadcn/ui Command component wraps cmdk with pre-styled Tailwind classes. This pattern shows a minimal setup with static navigation commands.

import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import {
  FileIcon,
  SettingsIcon,
  PaletteIcon,
  LayoutDashboardIcon,
} from "lucide-react";

const navigationItems = [
  { label: "Dashboard", href: "/dashboard", icon: LayoutDashboardIcon },
  { label: "Components", href: "/components", icon: FileIcon },
  { label: "Theme", href: "/theme", icon: PaletteIcon },
  { label: "Settings", href: "/settings", icon: SettingsIcon },
];

export function AppCommandPalette() {
  const [open, setOpen] = useState(false);
  const router = useRouter();

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((o) => !o);
      }
    };
    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>
        <CommandGroup heading="Navigation">
          {navigationItems.map((item) => (
            <CommandItem
              key={item.href}
              onSelect={() => {
                router.push(item.href);
                setOpen(false);
              }}
            >
              <item.icon className="mr-2 h-4 w-4" />
              <span>{item.label}</span>
            </CommandItem>
          ))}
        </CommandGroup>
        <CommandSeparator />
        <CommandGroup heading="Actions">
          <CommandItem onSelect={() => {
            document.documentElement.classList.toggle("dark");
            setOpen(false);
          }}>
            <PaletteIcon className="mr-2 h-4 w-4" />
            <span>Toggle Theme</span>
            <kbd className="ml-auto text-xs text-muted-foreground">Shift+T</kbd>
          </CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
}

Pattern 2: Context-Dependent Commands with useEffect Registration

This pattern shows how individual page components register their own commands that appear only while that page is mounted.

import { useEffect } from "react";
import { useCommandRegistry } from "@/lib/command-registry";
import { CopyIcon, PlayIcon, ExternalLinkIcon, CodeIcon } from "lucide-react";

interface ComponentPageProps {
  componentName: string;
  importPath: string;
  storybookUrl: string;
  sourceUrl: string;
}

export function ComponentPage({
  componentName,
  importPath,
  storybookUrl,
  sourceUrl,
}: ComponentPageProps) {
  const { register } = useCommandRegistry();

  useEffect(() => {
    const unregister = register([
      {
        id: `copy-import-${componentName}`,
        label: `Copy ${componentName} import`,
        group: "Component Actions",
        icon: <CopyIcon className="h-4 w-4" />,
        keywords: ["copy", "import", "clipboard"],
        onSelect: () => {
          navigator.clipboard.writeText(
            `import { ${componentName} } from "${importPath}";`
          );
        },
      },
      {
        id: `open-storybook-${componentName}`,
        label: `Open ${componentName} in Storybook`,
        group: "Component Actions",
        icon: <PlayIcon className="h-4 w-4" />,
        keywords: ["storybook", "preview", "playground"],
        onSelect: () => {
          window.open(storybookUrl, "_blank");
        },
      },
      {
        id: `view-source-${componentName}`,
        label: `View ${componentName} source`,
        group: "Component Actions",
        icon: <CodeIcon className="h-4 w-4" />,
        keywords: ["source", "code", "github"],
        onSelect: () => {
          window.open(sourceUrl, "_blank");
        },
      },
      {
        id: `copy-props-${componentName}`,
        label: `Copy ${componentName} props interface`,
        group: "Component Actions",
        icon: <CopyIcon className="h-4 w-4" />,
        keywords: ["props", "typescript", "interface", "types"],
        onSelect: async () => {
          const res = await fetch(`/api/components/${componentName}/props`);
          const data = await res.json();
          navigator.clipboard.writeText(data.propsInterface);
        },
      },
    ]);

    // Cleanup: remove these commands when the component unmounts
    return unregister;
  }, [componentName, importPath, storybookUrl, sourceUrl, register]);

  return (
    <div>
      <h1>{componentName}</h1>
      {/* Component documentation content */}
    </div>
  );
}

Common Pitfalls

Further Reading