# React Command Palette: Cmd+K, Nested Pages and Mobile

> Build a React command palette that binds Cmd+K on a Mac and Ctrl+K elsewhere, with nested pages, async results, recents and a full-height sheet on phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-command-palette

## TL;DR

A React command palette should bind one mod+K shortcut that reads ⌘K on Apple devices and Ctrl+K elsewhere, keep nested pages as a stack that Backspace and Escape pop, debounce and abort server searches, and store recents by item id. Phones have no Cmd key, so it also needs a visible search button that opens a full-height sheet with the keyboard already up and 16px input text. Wingo UI's Command Menu (Pro) does all of this on top of cmdk; the Kbd and useShortcut pieces it uses are free.

A React command palette is a search box in a dialog that lets people jump to a page, run an action or find a client without reaching for the mouse. With cmdk the first version is short. The details take longer: the hint has to read ⌘K on a Mac and Ctrl+K on Windows, "Theme…" should open a second page that Backspace closes, server results should arrive without flicker, recent picks should come first, and on a phone the whole thing should become a full-height sheet with the keyboard up. A basic cmd k search React example, shadcn's included, ends at the filtered list. This guide covers the rest with the [Command Menu](https://wingo-ui.com/components/command-menu) from Wingo UI, the library we build, so we are not neutral; outside facts link to their sources.

## What does a React command menu need?

It needs a device-aware shortcut, forgiving ranking, nested pages, async results, recents and a phone layout. Our checklist for a React command menu:

- **One shortcut, two notations.** `mod+k` binds ⌘K on Apple devices and Ctrl+K elsewhere, and the hint shows the right one.
- **Groups and ranking.** Matching ignores case and diacritics, so "cafe" finds "Café", and the matched letters turn bold.
- **Nested pages.** An item with a chevron opens a page; Backspace on an empty input or Escape goes back.
- **Async results** under the static ones, debounced, the previous request aborted.
- **Recents** by item id while the query is empty.
- **Phones**: a full-height sheet, 48px rows, a Cancel button, no key hints.

```bash
npx wingo-ui@latest add command-menu
```

> Live demo (Command Menu): Press ⌘J or Ctrl+J (the site keeps ⌘K) or click the search field, an icon on phones. Type "boston" for server results, pick "Theme…" to open a page, then press Backspace to go back. Try it at [Command Menu](https://wingo-ui.com/components/command-menu) and install it with `npx wingo-ui@latest add command-menu`.

## How do I bind Cmd+K on a Mac and Ctrl+K on Windows?

Write the shortcut once as `mod+k`, resolve `mod` to Meta on Apple devices and Control elsewhere, compare the physical key when the layout types another character, and render the label for the device. The [shadcn command palette example](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/examples/command-dialog.tsx) toggles on this check and prints a fixed ⌘J hint:

```tsx
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
  e.preventDefault()
  setOpen((open) => !open)
}
```

Three things are off. Ctrl+J also fires on a Mac. Windows users see a ⌘ key they do not have. And `event.key` is the character the layout types, which on a Cyrillic layout can be a Cyrillic letter; [`event.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) names the physical key and "isn't altered by keyboard layout". The free [useShortcut hook](https://wingo-ui.com/components/use-shortcut) handles all three, and the free [Kbd](https://wingo-ui.com/components/kbd) prints the label:

```tsx
"use client";

import { Kbd } from "@/components/ui/kbd";
import { useShortcut, useShortcutLabel } from "@/hooks/use-shortcut";

export function SearchButton({ onOpen }: { onOpen: () => void }) {
  const label = useShortcutLabel("mod+k");
  // meta on apple devices, ctrl elsewhere; the physical key on non-latin layouts
  useShortcut("mod+k", onOpen);
  return (
    <button type="button" onClick={onOpen} aria-keyshortcuts={label.aria}>
      Search <Kbd shortcut="mod+k" hideOnTouch />
    </button>
  );
}
```

- **The label renders as Ctrl+K on the server** and switches to ⌘K after hydration, because the server cannot know the platform. Same trap as an [SSR-safe useIsMobile hook](https://wingo-ui.com/blog/react-use-mobile-hook-ssr).
- **[`aria-keyshortcuts`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts)** announces the shortcut; MDN notes it "has no effect on the functionality of the page", so the listener still does the work.
- **Bare keys** such as `/` stay quiet while focus is in a text field. Combos with a modifier fire anywhere.

Ctrl+K is taken: as of October 2026, [Chrome's shortcut list](https://support.google.com/chrome/answer/157179) uses Ctrl+K on Windows and Linux to "Search from anywhere on the page". The listener calls `preventDefault()`, so the palette wins while the page has focus. [MDN advises](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-keyshortcuts#dont_override_browser_assistive_technology_or_operating_system_shortcuts) against taking a browser shortcut "unless they are used for the same thing", and a search palette is close to that.

One cmdk React detail bites on Windows: the [`vimBindings`](https://github.com/dip/cmdk/blob/main/cmdk/src/index.tsx) prop defaults to true, so Ctrl+J and Ctrl+K inside the list move the highlight and call `preventDefault()`. A listener that skips handled events, like useShortcut, then ignores the second Ctrl+K and the palette stays open. Pass `vimBindings={false}` if you want a toggle. The Command Menu keeps cmdk's default as of October 2026, so on Windows and Linux a second Ctrl+K moves the highlight up and Escape is the key that closes it.

> Live demo (useShortcut): Press Alt+K, or Option+K on a Mac where that key types ˚: the last row still lights up, and the line under it spells the combo for both platforms. Try it at [useShortcut](https://wingo-ui.com/components/use-shortcut) and install it with `npx wingo-ui@latest add use-shortcut`.

## How do nested pages work in a command palette?

Pages are a stack. An item with `page: "theme"` pushes that page and clears the query; Backspace on an empty input or Escape pops it; Escape on the first page closes the menu. Going back restores the query you had and highlights the row that opened the page, which [cmdk's own pages pattern](https://github.com/dip/cmdk) leaves to you. A page brings its own `groups`, or a `loadItems` that also runs for the empty query (the projects page in the full example below).

## How do I load async results without flicker?

Debounce the query, abort the request it replaces, and render server results under the local ones instead of swapping the list. The Command Menu calls `loadItems(query, { signal, page })` 200ms after the last keystroke (`debounce`), aborts the previous call through `signal`, shows a spinner in the input, keeps the static matches on screen, and turns a rejected promise into a "Could not load results" row with Try again. Pass `signal` to `fetch` so a stale request stops.

On raw cmdk, set `shouldFilter={false}` when you filter yourself or the server already did; otherwise cmdk filters and sorts the server's answer again. The Command Menu sets it too and ranks with its own text matcher. cmdk's README also says it has no virtualization, with "Good performance up to 2,000-3,000 items", so large data sets belong on the server.

## How should recents work in a command palette?

Store item ids, never labels, and only for things the person ran. Picks go to local storage under `recentKey` (`"command-menu:recent"`), and the last `maxRecent` (5) show in a Recent group while the query is empty. Page openers are not recorded. Server results stay in recents for the current session only, since a reload cannot rebuild them from the static groups. Prefix server ids (`client:42`) so they never collide with static ones: when two items share an id, only the first one renders, and development builds warn about duplicates inside `groups` and `pages`.

## What should a command palette do on a phone?

Open a full-height sheet with the keyboard already up. A phone has no ⌘ key, so the palette needs a visible trigger: `CommandMenuTrigger` reads "Search or jump to… ⌘K" on desktop and folds into an icon button below 768px. Below 768px the menu opens as a [bottom sheet](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web): an iOS-style search field, 48px rows, a Cancel button, swipe down to close, and no shortcut hints or footer on touch screens.

On iOS, a `focus()` call that does not come from the tap focuses the input and leaves the keyboard down. That is deliberate: in [WebKit bug 195884](https://bugs.webkit.org/show_bug.cgi?id=195884) an Apple engineer wrote in 2019 that Apple does "not want programmatic focus to bring up the keyboard", and the bug is still open in October 2026. The sheet's input does not exist yet when the tap lands, so the trigger focuses a hidden input inside the tap handler and hands focus to the real one once the sheet mounts.

Keep the input at 16px. Safari on iOS zooms into a focused input whose text is 15px or smaller ([CSS-Tricks](https://css-tricks.com/16px-or-larger-text-prevents-ios-form-zoom/)), and shadcn's [`CommandInput`](https://github.com/shadcn-ui/ui/blob/main/apps/v4/registry/new-york-v4/ui/command.tsx) uses `text-sm` (14px) as of October 2026.

> Live demo (Command Menu): This copy always opens as the phone sheet. Click the field or press Alt+P (Option+P on a Mac), type "inv", then press Cancel or drag the sheet down. Try it at [Command Menu](https://wingo-ui.com/components/command-menu) and install it with `npx wingo-ui@latest add command-menu`.

## Is a cmdk command palette accessible?

Yes for the core widget. cmdk renders the input as a `combobox` that controls a `listbox` and sets `aria-activedescendant` to the highlighted option, so screen readers follow the highlighted row while focus stays in the input, and its README says labeling and ARIA attributes were "tested with Voice Over and Chrome DevTools". The Command Menu adds a hidden dialog title, `aria-keyshortcuts` on rows with a shortcut, and a polite live region that announces the result count after typing pauses. For translation, the input text comes from `placeholder`, `emptyText` and `loadingText`, and every other visible or spoken string from `labels`.

## Should you use the shadcn command palette, cmdk or a ready-made menu?

Use shadcn's Command when you want free MIT code and will write the shortcut, pages, async loading and phone layout yourself. Use cmdk directly for an unstyled base. Use the Command Menu when you want those as props. As of October 2026, from the [cmdk README](https://github.com/dip/cmdk) and the [shadcn/ui Command docs](https://ui.shadcn.com/docs/components/command):

| | cmdk 1.1.1 | shadcn/ui Command | Wingo UI Command Menu |
| --- | --- | --- | --- |
| What you get | Unstyled primitives, `Command.Dialog` on Radix | cmdk with Tailwind styles in a Dialog | One component configured by props |
| Price | Free, MIT | Free, MIT | Wingo UI Pro |
| Shortcut | You write it | Docs example checks Meta or Ctrl + J | `shortcut="mod+k"`, label per device |
| Nested pages | A pattern in the README | You build it | `pages`, breadcrumb, Back restores the row |
| Async results | `Command.Loading`; you fetch | You build it | `loadItems`: debounce, abort, retry |
| Recents | Not included | Not included | Persisted by id |
| Phones | No layout | Centered dialog | Full-height sheet below 768px |

If people mostly search records (clients, invoices) with scopes and "See all" links, a header field fits better: [Global Search](https://wingo-ui.com/components/global-search) binds `/`, groups results by type and opens full screen on phones. It works next to a palette: `/` to search, ⌘K for commands.

## How do I add a React command palette to a Next.js app?

Install it, hand the library your router once, and mount the menu in your app shell. `LinkProvider` lets link rows render through `next/link` (so Cmd+click opens a new tab) and lets Enter navigate with `router.push`:

```tsx
"use client";

import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { LinkProvider } from "@/lib/navigation";

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter();
  return (
    <LinkProvider component={Link} currentPath={usePathname()} navigate={router.push}>
      {children}
    </LinkProvider>
  );
}
```

The menu, with navigation, actions, two pages and server search:

```tsx
"use client";

import { FolderOpen, Moon, Sun, SunMoon, UserPlus } from "lucide-react";
import { useTheme } from "next-themes";
import { CommandMenu, CommandMenuTrigger, commandItemsFromNav, type CommandGroup, type CommandPage } from "@/components/ui/command-menu";
import type { NavItem } from "@/lib/navigation";

const nav: NavItem[] = [
  { label: "Clients", href: "/clients" },
  { label: "Invoices", href: "/invoices", items: [{ label: "Overdue", href: "/invoices/overdue" }] },
];

async function search<T>(url: string, signal: AbortSignal): Promise<T[]> {
  const res = await fetch(url, { signal });
  if (!res.ok) throw new Error(`search failed: ${res.status}`);
  return res.json();
}

export function AppCommandMenu() {
  const { setTheme } = useTheme();

  const groups: CommandGroup[] = [
    { label: "Go to", items: commandItemsFromNav(nav) },
    {
      label: "Actions",
      items: [
        { id: "new-client", label: "New client", icon: <UserPlus />, shortcut: "alt+n", href: "/clients/new" },
        { id: "open-project", label: "Open project…", icon: <FolderOpen />, page: "projects" },
        { id: "theme", label: "Theme…", icon: <SunMoon />, page: "theme" },
      ],
    },
  ];

  const pages: CommandPage[] = [
    {
      id: "theme",
      title: "Theme",
      groups: [
        {
          items: [
            { id: "light", label: "Light", icon: <Sun />, onSelect: () => setTheme("light") },
            { id: "dark", label: "Dark", icon: <Moon />, onSelect: () => setTheme("dark") },
          ],
        },
      ],
    },
    {
      id: "projects",
      title: "Projects",
      placeholder: "Find a project…",
      // a page loader also runs for the empty query, so the page opens with a list
      loadItems: async (query, { signal }) => {
        const projects = await search<{ id: string; name: string }>(`/api/projects?q=${encodeURIComponent(query)}`, signal);
        return [{ items: projects.map((p) => ({ id: `project:${p.id}`, label: p.name, href: `/projects/${p.id}` })) }];
      },
    },
  ];

  return (
    <CommandMenu
      groups={groups}
      pages={pages}
      loadItems={async (query, { signal }) => {
        const clients = await search<{ id: string; name: string; city: string }>(`/api/clients?q=${encodeURIComponent(query)}`, signal);
        return [{ label: "Clients", items: clients.map((c) => ({ id: `client:${c.id}`, label: c.name, description: c.city, href: `/clients/${c.id}` })) }];
      }}
      trigger={<CommandMenuTrigger />}
    />
  );
}
```

`commandItemsFromNav` turns your sidebar items into "Go to" rows, and nested ones read "Invoices › Overdue". An `onSelect` that returns a promise keeps the menu open with a spinner on that row until it settles. Render `<AppCommandMenu />` in your app header, inside `Providers` and the next-themes `ThemeProvider`, so link rows reach the router and the Theme page can switch themes.

The Command Menu and Global Search are part of Wingo UI Pro, $8 a month, $80 a year or $150 once as of October 2026 ([pricing](https://wingo-ui.com/pricing)). Kbd and useShortcut are free: `npx wingo-ui@latest add kbd use-shortcut`. More in the [component guides](https://wingo-ui.com/blog/category/components), starting with the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide).

## Components in this post

- [Command Menu](https://wingo-ui.com/components/command-menu) (Pro): The ⌘K palette: search and run anything with groups, shortcuts, nested pages, async results and recents, and a full-height sheet on phones. Install: `npx wingo-ui@latest add command-menu`
- [useShortcut](https://wingo-ui.com/components/use-shortcut) (Free): Keyboard shortcuts in mod+k notation: bind them, match events and show them as ⌘K on a Mac or Ctrl+K elsewhere. Install: `npx wingo-ui@latest add use-shortcut`
- [Kbd](https://wingo-ui.com/components/kbd) (Free): Keyboard keys and combos in the notation of the device: ⌘K on a Mac, Ctrl+K everywhere else. Install: `npx wingo-ui@latest add kbd`
- [Global Search](https://wingo-ui.com/components/global-search) (Pro): The search field of an app header: grouped results from every entity as you type, scopes, recents and suggestions, full-screen on phones. Install: `npx wingo-ui@latest add global-search`

## FAQ

### How do I open a command palette with Cmd+K in React?

Listen for keydown on window and toggle the palette when Meta and K are pressed on Apple devices, or Control and K elsewhere, then call preventDefault so the browser's own Ctrl+K search does not run. Wingo UI's free useShortcut hook, called as useShortcut("mod+k", toggle), does the platform check and also matches the physical K key on non-Latin layouts.

### Does cmdk filter async results?

Yes, by default cmdk filters and sorts every rendered item, server results included. Set shouldFilter={false} on Command when you filter yourself or the server already returns matches, and render Command.Loading while the request runs.

### How many items can cmdk handle?

The cmdk README says it has no virtualization and performs well up to 2,000 to 3,000 items. Past that, search on the server and return the top matches.

### Why does a second Ctrl+K not close my cmdk palette on Windows?

cmdk's vimBindings prop defaults to true, so Ctrl+J and Ctrl+K inside the palette move the highlight and call preventDefault. A shortcut listener that skips handled events then ignores the press; pass vimBindings={false} if you want Ctrl+K to toggle.

### How should a command palette work on mobile?

Give it a visible search button, since phones have no Cmd key, and open it as a full-height sheet with the input focused inside the tap so iOS raises the keyboard. Use 16px input text so Safari does not zoom, and rows at least 44px tall.

### Is the Wingo UI command menu free?

No. The Command Menu and Global Search are part of Wingo UI Pro. The Kbd component and the useShortcut hook are free and install with npx wingo-ui@latest add kbd use-shortcut.

---

Source: https://wingo-ui.com/blog/react-command-palette
