Skip to content

Build a command menu in React

Open it with Cmd K, filter as people type, group results, drive it from the keyboard, and keep it instant for the hundredth use of the day.

On this page
  1. When a command menu helps
  2. Install the component
  3. Describe your commands
  4. Open it with Cmd K
  5. Search that finds the right thing
  6. Keyboard and screen readers
  7. Keep it instant
  8. Run the command

When a command menu helps

A command menu is a search field that runs actions. People open it with a shortcut, type a few letters, and press Enter. It suits products with more actions than the navigation can show, and people who already know what they want.

It does not replace navigation. Someone new to your product still needs visible buttons and menus to find things. Treat the command menu as the fast path for the second week, not the only path on the first day.

Try typing “invite” or “dark”

A live Arc command palette in a dialog. Filter, move with the arrow keys, and press Enter. Nothing is changed outside this preview.

Install the component

Arc's command palette is free and installs with the shadcn CLI. Register the @uiarc namespace once, then add it by name. The source lands in your project, so you can change anything later.

Terminal
# once per project: register the Arc namespace in components.json# { "registries": { "@uiarc": "https://uiarc.dev/r/{name}.json" } } npx shadcn@latest add @uiarc/command-palette

The first Arc install also brings registry/foundation.css with the color, type and motion tokens. Import it once in your root layout.

Describe your commands

Each command is a plain object. Only id and label are required. The rest makes the menu easier to scan and search:

  • group puts related commands under one heading, in the order the groups first appear.
  • description answers “what will this do?” in a few words.
  • keywords catch the words people type that are not in the label, like “member” for an invite.
  • shortcut shows the direct key, so the menu teaches people to skip it next time.
commands.tsx
import { FilePlus2, Settings, UserPlus } from "lucide-react";import type { CommandItem } from "@/registry/components/command-palette/command-palette"; export const commands: CommandItem[] = [  {    id: "new-project",    label: "New project",    description: "Start from a blank brief",    group: "Create",    icon: <FilePlus2 size={16} />,    shortcut: "N",    keywords: ["add", "create"],  },  {    id: "invite",    label: "Invite teammate",    group: "Create",    icon: <UserPlus size={16} />,    keywords: ["member", "people"],  },  {    id: "settings",    label: "Project settings",    group: "Project",    icon: <Settings size={16} />,    keywords: ["preferences"],  },];

Open it with Cmd K

Cmd K on macOS and Ctrl K elsewhere is the convention people already know. Listen on window, prevent the browser default, and let the same keys close it. Put the palette inside a dialog so focus is trapped, Escape closes it, and focus returns to where it was.

command-menu.tsx
"use client"; import { useEffect, useState } from "react";import * as Dialog from "@radix-ui/react-dialog";import { CommandPalette } from "@/registry/components/command-palette/command-palette";import { commands } from "./commands"; export function CommandMenu({ onRun }: { onRun: (id: string) => void }) {  const [open, setOpen] = useState(false);   useEffect(() => {    function onKeyDown(event: KeyboardEvent) {      // Cmd K on macOS, Ctrl K elsewhere. The same keys close it again.      if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {        event.preventDefault();        setOpen(value => !value);      }    }    window.addEventListener("keydown", onKeyDown);    return () => window.removeEventListener("keydown", onKeyDown);  }, []);   return (    <Dialog.Root open={open} onOpenChange={setOpen}>      <Dialog.Portal>        <Dialog.Overlay className="overlay" />        <Dialog.Content className="menu" aria-describedby={undefined}>          <Dialog.Title className="sr-only">Commands</Dialog.Title>          <CommandPalette            items={commands}            placeholder="Type a command or search"            onClose={() => setOpen(false)}            onSelect={item => {              setOpen(false);              onRun(item.id);            }}          />        </Dialog.Content>      </Dialog.Portal>    </Dialog.Root>  );}

The palette matches the query against the label, description, group and keywords, and keeps your order. That makes order your ranking: put the likeliest commands first in each group, and put recent items above everything else.

command-menu.tsx
// Build the list per page, so the menu only offers what makes sense here.const items = useMemo(() => [  ...recentProjects.map(project => ({    id: `open:${project.id}`,    label: project.name,    group: "Recent",    keywords: [project.client, project.owner],  })),  ...commands,], [recentProjects]);

Keep the list to what applies on the current page. A menu that offers “Archive project” on the billing page makes people read more to find less. With a few hundred items, filtering in memory is instant. Past a few thousand, search on the server and pass in the results.

Always show something when nothing matches. The palette shows an empty state instead of a blank box, so people know the search ran and can try another word.

Keyboard and screen readers

Focus stays in the input the whole time. The arrow keys move a highlight through the results, Home and End jump to the ends, Enter runs the highlighted command, and Escape clears the query first and closes the menu second.

Under the hood the input is a combobox that points at a listbox, and aria-activedescendant tells assistive technology which option is highlighted without moving focus. Results are grouped with role="group" and a label, so a screen reader announces the group as the highlight enters it.

Keep it instant

A command menu can be opened a hundred times a day. Anything that delays it becomes the thing people notice. Arc's palette keeps the entrance to a short fade and settle, and uses motion only where it explains a change:

  • The highlight slides between results in 70 ms when you use the arrow keys, and follows the pointer on a quick spring.
  • When a keystroke filters rows out, the remaining rows close the gap and the frame shrinks to fit, so the list never jumps.
  • With reduced motion turned on, all of it snaps into place and the feedback stays the same.

If you build your own, skip open and close animations that last longer than about 150 ms, and never animate the text people are typing. For more on this, read animating React interfaces with Motion.

Run the command

Close the menu first, then act. Navigation, opening another dialog, and toggling a setting all feel right after the menu is gone. For anything destructive, open a confirmation instead of acting straight away. Someone who pressed Enter one row too late should not lose a project.

command-menu.tsx
function runCommand(id: string) {  if (id.startsWith("open:")) return router.push(`/projects/${id.slice(5)}`);  switch (id) {    case "new-project":      return router.push("/projects/new");    case "invite":      return setInviteOpen(true);    case "archive":      // Destructive: hand off to a confirmation, never act straight from the menu.      return setConfirmArchive(true);  }}

When a command finishes, show the result where it happened, like a toast or the changed view. The menu has already closed, so it is not the place to report success.

More guides