# Command Palette

A searchable command palette with groups, nested pages, and recent actions.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/command-palette.json
```

[Registry JSON](https://mwui.vercel.app/r/command-palette.json)

## Preview

```tsx
import {
  IconFilePlus,
  IconMoon,
  IconSettings,
  IconSun,
  IconUserPlus,
  IconUsers,
} from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";

import { CommandPalette, type CommandAction } from "@/components/ui/command-palette";

export function Preview() {
  const [open, setOpen] = React.useState(false);
  const [log, setLog] = React.useState("Nothing run yet.");

  const actions: CommandAction[] = React.useMemo(
    () => [
      {
        id: "new-project",
        label: "New project",
        description: "Start from a blank workspace",
        group: "Create",
        shortcut: "⌘ N",
        icon: <IconFilePlus />,
        onSelect: () => setLog("Ran: New project"),
      },
      {
        id: "invite",
        label: "Invite teammate",
        group: "Create",
        keywords: ["member", "seat", "user"],
        icon: <IconUserPlus />,
        onSelect: () => setLog("Ran: Invite teammate"),
      },
      {
        id: "members",
        label: "Go to members",
        group: "Navigate",
        icon: <IconUsers />,
        onSelect: () => setLog("Ran: Go to members"),
      },
      {
        id: "theme",
        label: "Change theme",
        group: "Preferences",
        icon: <IconSettings />,
        children: [
          {
            id: "theme-light",
            label: "Light",
            icon: <IconSun />,
            onSelect: () => setLog("Ran: Theme → Light"),
          },
          {
            id: "theme-dark",
            label: "Dark",
            icon: <IconMoon />,
            onSelect: () => setLog("Ran: Theme → Dark"),
          },
        ],
      },
    ],
    [],
  );

  return (
    <div className="flex w-full max-w-sm flex-col gap-3 text-sm">
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open palette
        <kbd className="ml-auto rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.7rem]">
          ⌘K
        </kbd>
      </Button>

      <p className="text-xs text-muted-foreground">{log}</p>

      <CommandPalette
        actions={actions}
        open={open}
        onOpenChange={setOpen}
        recentsKey="mwui:command-palette-preview"
      />
    </div>
  );
}
```


## Source

### ui/command-palette.tsx

```tsx
"use client";

import { IconChevronRight, IconCornerDownLeft } from "@tabler/icons-react";
import * as React from "react";

import {
  CommandDialog,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandShortcut,
} from "@/components/ui/command";
import { useHotkeys } from "@/hooks/use-hotkeys";
import { useLocalStorage } from "@/hooks/use-local-storage";
import { cn } from "@/lib/utils";

type CommandAction = {
  id: string;
  label: string;
  description?: string;
  group?: string;
  keywords?: string[];
  shortcut?: string;
  icon?: React.ReactNode;
  disabled?: boolean;
  children?: CommandAction[];
  onSelect?: () => void;
};

type CommandPalettePage = {
  title: string;
  actions: CommandAction[];
};

type CommandPaletteProps = {
  actions: CommandAction[];
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  hotkey?: string | null;
  placeholder?: string;
  emptyMessage?: string;
  recentsKey?: string | null;
  maxRecents?: number;
};

function CommandPalette({
  actions,
  open: openProp,
  onOpenChange,
  hotkey = "mod+k",
  placeholder = "Type a command or search…",
  emptyMessage = "No results found.",
  recentsKey = "command-palette:recents",
  maxRecents = 5,
}: CommandPaletteProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
  const open = openProp ?? uncontrolledOpen;

  const [pages, setPages] = React.useState<CommandPalettePage[]>([]);
  const [query, setQuery] = React.useState("");
  const [recentIds, setRecentIds] = useLocalStorage<string[]>(
    recentsKey ?? "command-palette:disabled",
    [],
  );

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(next);
      onOpenChange?.(next);
    },
    [onOpenChange, openProp],
  );

  useHotkeys(hotkey ?? "", () => setOpen(!open), { enabled: hotkey !== null });

  React.useEffect(() => {
    if (open) return undefined;

    const timeout = setTimeout(() => {
      setPages([]);
      setQuery("");
    }, 150);

    return () => clearTimeout(timeout);
  }, [open]);

  const currentPage = pages[pages.length - 1];
  const visibleActions = currentPage?.actions ?? actions;

  const flatActions = React.useMemo(() => flatten(actions), [actions]);

  const recents = React.useMemo(() => {
    if (recentsKey === null || currentPage || query) return [];

    return recentIds
      .map((id) => flatActions.find((action) => action.id === id))
      .filter((action): action is CommandAction => action !== undefined)
      .slice(0, maxRecents);
  }, [currentPage, flatActions, maxRecents, query, recentIds, recentsKey]);

  const recentIdSet = new Set(recents.map((action) => action.id));

  const runAction = (action: CommandAction) => {
    if (action.children && action.children.length > 0) {
      setPages((previous) => [
        ...previous,
        { title: action.label, actions: action.children ?? [] },
      ]);
      setQuery("");
      return;
    }

    if (recentsKey !== null) {
      setRecentIds((previous) =>
        [action.id, ...previous.filter((id) => id !== action.id)].slice(0, maxRecents),
      );
    }

    setOpen(false);
    action.onSelect?.();
  };

  const goBack = () => {
    setPages((previous) => previous.slice(0, -1));
    setQuery("");
  };

  const groups = groupActions(visibleActions.filter((action) => !recentIdSet.has(action.id)));

  return (
    <CommandDialog
      open={open}
      onOpenChange={setOpen}
      title="Command palette"
      description="Search for a command to run."
    >
      {pages.length > 0 ? (
        <div className="flex items-center gap-1 px-2 pt-2 text-xs text-muted-foreground">
          <button
            type="button"
            onClick={goBack}
            className="rounded px-1 py-0.5 outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
          >
            Home
          </button>
          {pages.map((page, index) => (
            <React.Fragment key={page.title}>
              <IconChevronRight className="size-3" />
              <span className={cn(index === pages.length - 1 && "text-foreground")}>
                {page.title}
              </span>
            </React.Fragment>
          ))}
        </div>
      ) : null}

      <CommandInput
        value={query}
        onValueChange={setQuery}
        placeholder={currentPage ? `Search ${currentPage.title.toLowerCase()}…` : placeholder}
        onKeyDown={(event) => {
          if (event.key === "Backspace" && query === "" && pages.length > 0) {
            event.preventDefault();
            goBack();
          }
        }}
      />

      <CommandList>
        <CommandEmpty>{emptyMessage}</CommandEmpty>

        {recents.length > 0 ? (
          <CommandGroup heading="Recent">
            {recents.map((action) => (
              <CommandActionItem key={action.id} action={action} onRun={runAction} />
            ))}
          </CommandGroup>
        ) : null}

        {groups.map(({ heading, items }) => (
          <CommandGroup key={heading ?? "ungrouped"} heading={heading}>
            {items.map((action) => (
              <CommandActionItem key={action.id} action={action} onRun={runAction} />
            ))}
          </CommandGroup>
        ))}
      </CommandList>
    </CommandDialog>
  );
}

function CommandActionItem({
  action,
  onRun,
}: {
  action: CommandAction;
  onRun: (action: CommandAction) => void;
}) {
  const hasChildren = action.children !== undefined && action.children.length > 0;

  return (
    <CommandItem
      value={`${action.label} ${action.keywords?.join(" ") ?? ""} ${action.description ?? ""}`}
      disabled={action.disabled}
      onSelect={() => onRun(action)}
    >
      {action.icon ? (
        <span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
          {action.icon}
        </span>
      ) : null}

      <span className="flex min-w-0 flex-1 flex-col">
        <span className="truncate">{action.label}</span>
        {action.description ? (
          <span className="truncate text-xs text-muted-foreground">{action.description}</span>
        ) : null}
      </span>

      {action.shortcut ? <CommandShortcut>{action.shortcut}</CommandShortcut> : null}

      {hasChildren ? (
        <IconChevronRight className="size-3.5 text-muted-foreground" />
      ) : (
        <IconCornerDownLeft className="size-3.5 text-muted-foreground opacity-0 in-data-[selected=true]:opacity-100" />
      )}
    </CommandItem>
  );
}

function flatten(actions: CommandAction[]): CommandAction[] {
  return actions.flatMap((action) => [action, ...flatten(action.children ?? [])]);
}

function groupActions(
  actions: CommandAction[],
): { heading: string | undefined; items: CommandAction[] }[] {
  const groups = new Map<string | undefined, CommandAction[]>();

  for (const action of actions) {
    const existing = groups.get(action.group);

    if (existing) {
      existing.push(action);
    } else {
      groups.set(action.group, [action]);
    }
  }

  return Array.from(groups, ([heading, items]) => ({ heading, items }));
}

export { CommandPalette, type CommandAction, type CommandPaletteProps };
```



## Usage

Describe your commands as data and get the whole ⌘K surface: fuzzy search, groups, keyboard
navigation, a nested page per action that has children, and a recents list that survives reloads.

```tsx
import { CommandPalette } from "@/components/ui/command-palette";

const ACTIONS = [
  {
    id: "new-project",
    label: "New project",
    group: "Create",
    shortcut: "⌘ N",
    onSelect: () => router.navigate({ to: "/projects/new" }),
  },
  {
    id: "theme",
    label: "Change theme",
    group: "Preferences",
    children: [
      { id: "theme-light", label: "Light", onSelect: () => setTheme("light") },
      { id: "theme-dark", label: "Dark", onSelect: () => setTheme("dark") },
    ],
  },
];

export function AppCommands() {
  return <CommandPalette actions={ACTIONS} />;
}
```

Mount it once, near the root. It binds `mod+k` itself, so nothing else needs wiring.

## Nested pages

An action with `children` opens them as a page instead of running. The breadcrumb shows where you
are, `Backspace` on an empty query goes back, and search applies to the current page only — which is
what makes a two-level menu (Change theme → Dark) feel faster than a flat list of every permutation.

## Recents

Selected actions are remembered in `localStorage` and shown above everything else when the query is
empty. They are resolved by `id` against the current action list, so a command you removed from the
app simply stops appearing.

```tsx
<CommandPalette actions={ACTIONS} recentsKey="acme:commands" maxRecents={3} />
```

Pass `recentsKey={null}` to turn the feature off — worth doing if the palette can act on data the
user may no longer be allowed to see.

## Controlled

Pass `open` and `onOpenChange` to drive it from elsewhere — a toolbar button, a route, an onboarding
step. Set `hotkey={null}` if you want to own the shortcut.

```tsx
<CommandPalette actions={ACTIONS} open={open} onOpenChange={setOpen} hotkey={null} />
```

## Action shape

| Field         | Description                                                                |
| ------------- | -------------------------------------------------------------------------- |
| `id`          | Stable identity. Used for recents, so keep it stable across releases.       |
| `label`       | What the user reads and searches.                                           |
| `description` | Second line, also searchable.                                               |
| `group`       | Heading to file the action under. Ungrouped actions render first.           |
| `keywords`    | Extra search terms — synonyms, the old name of a feature, a misspelling.    |
| `shortcut`    | Display only. Render the shortcut you bound elsewhere; the palette does not bind it. |
| `icon`        | Any node.                                                                   |
| `children`    | Opens a nested page instead of running.                                     |
| `onSelect`    | What the action does. Called after the palette closes, so a focus change lands cleanly. |

## Props

| Prop           | Description                                                             |
| -------------- | ----------------------------------------------------------------------- |
| `actions`      | The root action list.                                                    |
| `open` / `onOpenChange` | Control the palette externally.                                 |
| `hotkey`       | Toggle shortcut. Defaults to `"mod+k"` — ⌘ on Apple platforms, Ctrl elsewhere. `null` disables it. |
| `placeholder`  | Input placeholder on the root page.                                      |
| `emptyMessage` | Shown when nothing matches.                                              |
| `recentsKey`   | `localStorage` key for recents. `null` disables them.                    |
| `maxRecents`   | How many to keep. Defaults to `5`.                                       |

