# Command Palette

> An animated command palette for React and shadcn, opened with ⌘K, where Enter runs the first match.

A ⌘K command palette that rises 8px at 0.96 on a spring when clicked open and appears instantly from the hotkey, then leaves 4px in 160ms. Enter runs the first match.

- Tier: Free (MIT)
- Interaction: Type
- Page: https://eased.dev/components/command-palette
- Installs: @base-ui/react
- Registry: https://eased.dev/r/command-palette.json

## Install

```bash
npx shadcn add https://eased.dev/r/command-palette.json
```

A ⌘K palette of commands that filters as you type, built on Base UI Dialog and Autocomplete and the Eased overlay core.

## When to use

- One place to run any action or jump anywhere in an app: create an issue, go to a page, switch theme, open a teammate.
- Power users who live on the keyboard: ⌘K (Ctrl K off Apple) toggles it from anywhere, even from inside a text field, and Enter runs the first match.
- A long list of actions that would overflow a Dropdown Menu, or that people find by name rather than by position.

## When not to use

- Picking a value for a form field: use Combobox or Select, which keep the choice.
- Searching content inside the page (documents, rows): use Search Field and filter in place.
- A handful of actions on one object: a Dropdown Menu or Context Menu sits next to the thing they act on.

## Usage

```tsx example.tsx
import { CommandPalette, CommandPaletteTrigger, type CommandGroup } from "@/components/eased/command-palette/command-palette";

const groups: CommandGroup[] = [
  {
    label: "Suggestions",
    items: [
      { id: "create", label: "Create issue", shortcut: ["C"], keywords: ["new"], onSelect: () => createIssue() },
      { id: "docs", label: "Search docs", description: "Help", onSelect: () => openDocs() },
    ],
  },
  {
    label: "Navigation",
    items: [
      { id: "inbox", label: "Go to Inbox", shortcut: ["G", "I"], onSelect: () => router.push("/inbox") },
      { id: "settings", label: "Settings", onSelect: () => router.push("/settings") },
    ],
  },
];

export function Header() {
  return (
    <CommandPalette groups={groups}>
      <CommandPaletteTrigger className="w-56" />
    </CommandPalette>
  );
}
```

Each command's `onSelect` runs, then the palette closes and focus goes back to whatever had it.

## Examples

### Opening it from elsewhere

`useCommandPalette()` holds the open state so a menu item or a button far from the trigger can open it.

```tsx
const palette = useCommandPalette();

<CommandPalette {...palette.props} groups={groups} />
<Button onClick={palette.show}>Open commands</Button>
```

### A flat list and a different hotkey

```tsx
<CommandPalette items={commands} hotkey="mod+shift+p" placeholder="Run a command…" footer={false} />
```

### More than one palette on a page

Only the first palette mounted answers a given hotkey. To give it to whichever is visible, pass `hotkey={visible ? "mod+k" : false}` (the demo does this with an IntersectionObserver).

## API reference

### CommandPalette

| Prop | Type | Default | Description |
|---|---|---|---|
| `groups` | `CommandGroup[]` | | Commands in labelled groups (`{ label, items }`). Groups with no matches hide while filtering; a group with an empty label is set off by a rule. |
| `items` | `CommandItem[]` | | A flat list instead of groups. Ignored when `groups` is set. |
| `open` | `boolean` | | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Uncontrolled starting state. |
| `onOpenChange` | `(open: boolean) => void` | | Called on every open and close: trigger, hotkey, Esc, outside click, a command. |
| `hotkey` | `string \| false` | `"mod+k"` | Toggles the palette. `mod` is ⌘ on Apple platforms and Ctrl elsewhere; also `ctrl`, `meta`, `shift`, `alt`. Chords work inside text fields; bare keys (`"/"`) don't. |
| `placeholder` | `string` | `"Search or jump to…"` | The input's placeholder. |
| `inputLabel` | `string` | `"Search commands"` | The input's accessible name. |
| `label` | `string` | `"Command palette"` | The dialog's accessible name. |
| `emptyText` | `ReactNode \| (query: string) => ReactNode` | `"No results"` | Shown when nothing matches. |
| `footer` | `ReactNode \| false` | ↑↓ Navigate · ↵ Open · Esc Close | The bar under the list. |
| `loop` | `boolean` | `true` | Arrow keys wrap from the last command through the input to the first. |
| `container` | `HTMLElement \| null` | `document.body` | Portal target. |
| `className` | `string` | `""` | Appended to the popup's classes (width is `w-[36rem]`). |
| `children` | `ReactNode` | | Triggers, usually `CommandPaletteTrigger`. |

### CommandItem

| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique within the palette. |
| `label` | `string` | What the command says. Matched, with the matching run emphasised. |
| `icon` | `ReactNode` | 16px icon or a 20px `Avatar`, in the leading track. |
| `description` | `ReactNode` | Muted text in the trailing column. Matched when it is a string. |
| `shortcut` | `string[]` | Key caps in the trailing column, e.g. `["G", "I"]`. Display only. |
| `keywords` | `string[]` | Extra words that find the command. Not shown. |
| `onSelect` | `() => void` | Runs on click, tap or Enter; the palette then closes. |
| `disabled` | `boolean` | Shown but can't be highlighted or run. |

Every word of the query must appear in the label, keywords, description or group label, compared with Base UI's locale-aware `useFilter`.

### CommandPaletteTrigger

A button styled as a search field: magnifier, a muted label (`children`, default "Search…") and the hotkey's caps (`hotkey`, default `"mod+k"`, false to hide). Base UI `Dialog.Trigger` props pass through. It presses by colour, never by scale.

### useCommandPalette

`useCommandPalette(defaultOpen?)` returns `{ open, setOpen, show, hide, toggle, props }`; spread `props` onto `CommandPalette`.

## Keyboard

| Key | Action |
|---|---|
| `⌘K` / `Ctrl K` | Opens the palette with no animation, or closes it. Works from inside text fields. |
| Typing | Filters the list; the first match is always highlighted. |
| `↓` / `↑` | Moves the highlight; past the last command it wraps through the input (`loop`). |
| `Enter` | Runs the highlighted command and closes. |
| `Esc` | Closes. Focus returns to where it was before the palette opened. |
| `Tab` | Stays inside the palette (focus is trapped). |

## Accessibility

- A modal `dialog` named by `label`; inside it a combobox input (named by `inputLabel`, described by a hidden hint on how to move, run and close) controlling a `listbox` of options in labelled groups.
- The highlight is `aria-activedescendant`, so focus stays in the input while the arrows move; the highlighted row scrolls into view.
- "No results" is announced politely from a region that stays mounted. The default footer's caps are hidden from screen readers; the input's description says the same.
- The input advertises the hotkey with `aria-keyshortcuts`. A visually hidden Close button is reachable by screen readers.
- Focus moves to the input on open (on touch too, so the keyboard comes up) and returns to the trigger, or to the element that had focus when the hotkey was pressed.
- Rows are 40px, 44px under 640px. The list's height uses `dvh` so it stays above the on-screen keyboard.

## Motion

Opened by clicking its trigger, the palette rises 8px from 0.96 on the smooth spring while it fades in over 200ms, over a scrim that fades and blurs in 200ms. Opened from the hotkey it appears at once with no enter (rule 84: a keyboard-driven, high-frequency action), and the scrim still fades. Either way it leaves 4px at 0.98 in 160ms on the standard curve, never overshooting. Open and close are CSS transitions on Base UI's starting and ending styles, so a quick ⌘K ⌘K reverses mid-flight. Filtering and highlighting don't animate; the list's edges feather only where there is more to scroll. Under reduced motion it fades in 100ms and nothing moves.

## Notes for AI

Pick CommandPalette for a global "run anything" surface; pass commands as data in `groups`, each with an `onSelect`, and put `CommandPaletteTrigger` inside it as a child. Use `keywords` for synonyms rather than adding duplicate commands; use `useCommandPalette()` when something other than the trigger or hotkey must open it.

Don't wrap it in another Dialog or add your own ⌘K listener: it owns the hotkey (one palette per hotkey per page), focus trapping, return focus, filtering, highlight and Esc. Don't close it from `onSelect`; it closes itself. It is not a value picker: use Combobox when the choice must stay in a form.

## Related

- [Dialog](/components/dialog): the same surface and motion for any modal content.
- [Search Field](/components/search-field): a search input that filters content in place, with its own ⌘K focus hint.
- [Kbd](/components/kbd): the key caps in the footer and trigger.

## Example

`components/eased/command-palette/command-palette-demo.tsx`

```tsx
"use client";

import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Avatar } from "../avatar/avatar";
import { DemoFrame } from "../lib/demo-frame";
import { TextSwap } from "../text-swap/text-swap";
import { CommandPalette, CommandPaletteTrigger, type CommandGroup } from "./command-palette";

type Theme = "Light" | "Dark" | "System";

const ISSUES = [
  { id: "NW-482", title: "Offline edits lost after sign-out", status: "In progress" },
  { id: "NW-479", title: "Billing page flashes on first load", status: "In review" },
  { id: "NW-475", title: "Export CSV ignores the date filter", status: "Todo" },
  { id: "NW-471", title: "Invite email lands in spam for Outlook", status: "Todo" },
  { id: "NW-466", title: "Keyboard focus lost after closing a sheet", status: "Done" },
  { id: "NW-463", title: "Dark mode chart labels fail contrast", status: "Done" },
  { id: "NW-458", title: "Slow search on workspaces over 10k issues", status: "Done" },
];

const TEAM = [
  { name: "Mara Lindqvist", team: "Design" },
  { name: "Devon Okafor", team: "Platform" },
  { name: "Priya Raman", team: "Billing" },
  { name: "Tomás Herrera", team: "Mobile" },
];

/**
 * An issue tracker header with the palette's trigger. ⌘K toggles the palette only while the
 * demo is on screen, so a page of demos never has two palettes answering one shortcut.
 */
export function CommandPaletteDemo() {
  const [ran, setRan] = useState<{ label: string; n: number } | null>(null);
  const [theme, setTheme] = useState<Theme>("System");
  const sentinel = useRef<HTMLDivElement>(null);
  const visible = useOnScreen(sentinel);

  const groups = useMemo<CommandGroup[]>(() => {
    const run = (label: string) => () => setRan((r) => ({ label, n: (r?.n ?? 0) + 1 }));
    const pickTheme = (t: Theme) => () => {
      setTheme(t);
      run(`${t} theme`)();
    };
    return [
      {
        label: "Suggestions",
        items: [
          { id: "create", label: "Create issue", icon: <PlusIcon />, shortcut: ["C"], keywords: ["new", "bug", "task"], onSelect: run("Create issue") },
          { id: "docs", label: "Search docs", icon: <BookIcon />, description: "Help", keywords: ["help", "guide"], onSelect: run("Search docs") },
        ],
      },
      {
        label: "Navigation",
        items: [
          { id: "inbox", label: "Go to Inbox", icon: <InboxIcon />, shortcut: ["G", "I"], onSelect: run("Go to Inbox") },
          { id: "projects", label: "Go to Projects", icon: <FolderIcon />, shortcut: ["G", "P"], onSelect: run("Go to Projects") },
          { id: "settings", label: "Settings", icon: <GearIcon />, keywords: ["preferences", "account"], onSelect: run("Settings") },
        ],
      },
      {
        label: "Theme",
        items: (["Light", "Dark", "System"] as const).map((t) => ({
          id: `theme-${t}`,
          label: `${t} theme`,
          icon: t === "Light" ? <SunIcon /> : t === "Dark" ? <MoonIcon /> : <MonitorIcon />,
          description: theme === t ? "Current" : undefined,
          keywords: ["appearance", "mode"],
          onSelect: pickTheme(t),
        })),
      },
      {
        label: "Team",
        items: TEAM.map((p) => ({
          id: p.name,
          label: p.name,
          icon: <Avatar name={p.name} size="xs" />,
          description: p.team,
          keywords: ["assign", "people"],
          onSelect: run(`Open ${p.name}`),
        })),
      },
    ];
  }, [theme]);

  return (
    <DemoFrame
      title={
        <>
          <span aria-hidden className="flex size-5 shrink-0 items-center justify-center rounded-control-xs bg-brand text-ui-2xs font-medium text-text-on-brand">
            N
          </span>
          <p className="truncate">Issues</p>
        </>
      }
      actions={
        <CommandPalette groups={groups} hotkey={visible ? "mod+k" : false} emptyText={(q) => `No commands match “${q}”`}>
          <CommandPaletteTrigger className="w-44 @max-sm:w-32" />
        </CommandPalette>
      }
    >
      <div ref={sentinel} aria-hidden className="pointer-events-none absolute inset-0" />
      <ul className="grid min-h-0 flex-1 grid-cols-[auto_minmax(0,1fr)_auto] content-start gap-x-3 overflow-hidden px-(--pad) py-2" aria-label="Issues">
        {ISSUES.map((issue) => (
          <li key={issue.id} className="col-span-full grid h-10 grid-cols-subgrid items-center border-b border-line-subtle text-ui-sm last:border-b-0">
            <span className="font-mono text-ui-xs text-text-muted tabular-nums slashed-zero">{issue.id}</span>
            <span className="truncate text-text">{issue.title}</span>
            <Status>{issue.status}</Status>
          </li>
        ))}
      </ul>
      <div className="flex h-11 shrink-0 items-center gap-1.5 border-t border-line-subtle px-(--pad) text-ui-sm">
        <span className="text-text-muted">Ran:</span>
        <span aria-live="polite" className="min-w-0 truncate text-text">
          <TextSwap swapKey={ran?.n ?? 0}>{ran?.label ?? "nothing yet"}</TextSwap>
        </span>
      </div>
    </DemoFrame>
  );
}

/** True while the element is at least partly in the viewport. */
function useOnScreen(ref: RefObject<HTMLElement | null>) {
  const [on, setOn] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => setOn(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [ref]);
  return on;
}

function Status({ children }: { children: string }) {
  const dot = children === "Done" ? "bg-success" : children === "In progress" ? "bg-warning" : children === "In review" ? "bg-info" : "bg-text-muted";
  return (
    <span className="flex w-22 items-center gap-1.5 text-ui-xs text-text-muted @max-sm:w-auto">
      <span aria-hidden className={`size-1.5 shrink-0 rounded-pill ${dot}`} />
      <span className="@max-sm:sr-only">{children}</span>
    </span>
  );
}

function Icon({ children }: { children: ReactNode }) {
  return (
    <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
      {children}
    </svg>
  );
}

const PlusIcon = () => <Icon><path d="M8 3.5v9M3.5 8h9" /></Icon>;
const BookIcon = () => <Icon><path d="M3.5 12.5v-9c0-.55.45-1 1-1h8v10h-8a1 1 0 0 0-1 1Zm0 0c0 .55.45 1 1 1h8" /></Icon>;
const InboxIcon = () => <Icon><path d="M2.5 9h3l1 1.5h3l1-1.5h3M4.25 3.5h7.5l1.75 5.5v3.5c0 .55-.45 1-1 1h-10c-.55 0-1-.45-1-1V9Z" /></Icon>;
const FolderIcon = () => <Icon><path d="M2.5 4.5c0-.55.45-1 1-1h3l1.5 1.5h4.5c.55 0 1 .45 1 1v6c0 .55-.45 1-1 1h-9c-.55 0-1-.45-1-1Z" /></Icon>;
const GearIcon = () => <Icon><circle cx="8" cy="8" r="2" /><path d="M8 2v1.5M8 12.5V14M2 8h1.5M12.5 8H14M3.75 3.75l1.06 1.06M11.19 11.19l1.06 1.06M3.75 12.25l1.06-1.06M11.19 4.81l1.06-1.06" /></Icon>;
const SunIcon = () => <Icon><circle cx="8" cy="8" r="2.75" /><path d="M8 1.75v1M8 13.25v1M1.75 8h1M13.25 8h1M3.6 3.6l.7.7M11.7 11.7l.7.7M3.6 12.4l.7-.7M11.7 4.3l.7-.7" /></Icon>;
const MoonIcon = () => <Icon><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5Z" /></Icon>;
const MonitorIcon = () => <Icon><rect x="2" y="3" width="12" height="8" rx="1" /><path d="M6 13.5h4M8 11v2.5" /></Icon>;
```
