DEV Community

Cover image for React Command Palette: Cmd+K, Nested Pages and Mobile
Serban Dan Rusu for Wingo UI

Posted on Originally published at wingo-ui.com

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

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 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.
npx wingo-ui@latest add command-menu
Enter fullscreen mode Exit fullscreen mode

Live demo: Command Menu. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Command Menu docs. Install: 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 toggles on this check and prints a fixed ⌘J hint:

if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
  e.preventDefault()
  setOpen((open) => !open)
}
Enter fullscreen mode Exit fullscreen mode

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 names the physical key and "isn't altered by keyboard layout". The free useShortcut hook handles all three, and the free Kbd prints the label:

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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.
  • 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 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 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 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. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the useShortcut docs. Install: 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 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: 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 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), and shadcn's CommandInput uses text-sm (14px) as of October 2026.

Live demo: Command Menu. The original article embeds an interactive demo here (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 live on wingo-ui.com or open the Command Menu docs. Install: 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 and the shadcn/ui Command docs:

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 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:

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

"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 />}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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). Kbd and useShortcut are free: npx wingo-ui@latest add kbd use-shortcut. More in the component guides, starting with the React form components guide.

Components in this post

  • 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 (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 (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 (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.


This article was originally published on the Wingo UI blog: React Command Palette: Cmd+K, Nested Pages and Mobile. The original has the live, interactive demos and is kept up to date.

Top comments (0)