# Command Palette in React and Next.js: Cmd+K with cmdk, Done Right

> Use cmdk inside a Radix dialog, mounted once in the root layout. Listen for Cmd or Ctrl plus K and call preventDefault, add a hidden Dialog.Title, hand focus back when the dialog closes, reset the query on every close, and mark server results forceMount so cmdk doesn't filter them away.

- URL: https://qodesign.dev/blog/command-palette-react-nextjs-cmdk
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Next.js fixes
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

A command palette in React is a dialog with a search box and a keyboard-driven list, opened with Cmd+K on a Mac and Ctrl+K elsewhere. The quickest solid way to build one in Next.js is `cmdk` for the list and filtering, inside a Radix dialog for focus trapping and Escape. That's the same pair shadcn/ui's Command component uses. The parts people get wrong are small: the browser's own Ctrl+K, a dialog with no title, focus that doesn't come back, and server results that cmdk filters away.

I built qodesign's [Command-K Dashboard](/components/dashboard-saas), and the version below is what I'd drop into any App Router app. It ran on a fresh Next.js 16.4 app with React 19.3, cmdk 1.1, @radix-ui/react-dialog 1.3 and Tailwind CSS v4, and I drove it in headless Chromium after `next build` and `next start`.

## Install

```bash
npm i cmdk @radix-ui/react-dialog
```

cmdk ships its own `Command.Dialog`, which wraps the same Radix dialog. I use Radix directly so the title, the focus handling and the classes all sit in one place where you can see them.

## The CommandPalette component

Save this as `components/CommandPalette.tsx`:

```tsx
"use client";

import { Command } from "cmdk";
import * as Dialog from "@radix-ui/react-dialog";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";

const pages = [
  { label: "Dashboard", href: "/" },
  { label: "Settings", href: "/settings", keywords: ["account", "profile"] },
  { label: "Billing", href: "/billing", keywords: ["plan", "invoice", "stripe"] },
];

type Doc = { slug: string; title: string };

export function CommandPalette() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [docs, setDocs] = useState<Doc[]>([]);
  const [loading, setLoading] = useState(false);
  const router = useRouter();
  const returnFocus = useRef<HTMLElement | null>(null);

  // Cmd+K on Mac, Ctrl+K everywhere else
  useEffect(() => {
    function onKeyDown(e: KeyboardEvent) {
      if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault(); // stops the browser's own Ctrl+K (search bar)
        returnFocus.current = document.activeElement as HTMLElement | null;
        setOpen((o) => !o);
      }
    }
    document.addEventListener("keydown", onKeyDown);
    return () => document.removeEventListener("keydown", onKeyDown);
  }, []);

  // Server search, debounced, ignoring answers that arrive late
  useEffect(() => {
    const q = query.trim();
    if (q.length < 2) {
      setDocs([]);
      return;
    }
    const controller = new AbortController();
    const timer = setTimeout(async () => {
      setLoading(true);
      try {
        const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
          signal: controller.signal,
        });
        setDocs(await res.json());
      } catch {
        // aborted: a newer query is on its way
      } finally {
        if (!controller.signal.aborted) setLoading(false);
      }
    }, 200);
    return () => {
      clearTimeout(timer);
      controller.abort();
    };
  }, [query]);

  function changeOpen(next: boolean) {
    setOpen(next);
    if (!next) setQuery("");
  }

  function go(href: string) {
    changeOpen(false);
    router.push(href);
  }

  return (
    <Dialog.Root open={open} onOpenChange={changeOpen}>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/40" />
        <Dialog.Content
          // No Dialog.Trigger here, so hand focus back to where it was
          onCloseAutoFocus={(e) => {
            if (returnFocus.current?.isConnected) {
              e.preventDefault();
              returnFocus.current.focus();
            }
          }}
          className="fixed left-1/2 top-[15vh] w-[min(92vw,560px)] -translate-x-1/2 overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-2xl">
          <Dialog.Title className="sr-only">Command menu</Dialog.Title>
          <Dialog.Description className="sr-only">
            Search pages and docs, then press Enter to open one.
          </Dialog.Description>
          <Command label="Command menu" loop>
            <Command.Input
              value={query}
              onValueChange={setQuery}
              placeholder="Search pages and docs..."
              className="w-full border-b border-zinc-200 px-4 py-3 text-sm outline-none"
            />
            <Command.List className="max-h-80 overflow-y-auto p-2">
              {loading && <Command.Loading>Searching...</Command.Loading>}
              <Command.Empty className="p-4 text-sm text-zinc-500">
                No results.
              </Command.Empty>

              <Command.Group heading="Pages" className="text-xs text-zinc-500">
                {pages.map((p) => (
                  <Command.Item
                    key={p.href}
                    value={`page ${p.label}`}
                    keywords={p.keywords}
                    onSelect={() => go(p.href)}
                    className="cursor-pointer rounded-md px-3 py-2 text-sm text-zinc-900 data-[selected=true]:bg-zinc-100"
                  >
                    {p.label}
                  </Command.Item>
                ))}
              </Command.Group>

              {docs.length > 0 && (
                <Command.Group heading="Docs" className="text-xs text-zinc-500">
                  {docs.map((d) => (
                    <Command.Item
                      key={d.slug}
                      value={`doc ${d.slug}`}
                      forceMount // the server already filtered these
                      onSelect={() => go(`/docs/${d.slug}`)}
                      className="cursor-pointer rounded-md px-3 py-2 text-sm text-zinc-900 data-[selected=true]:bg-zinc-100"
                    >
                      {d.title}
                    </Command.Item>
                  ))}
                </Command.Group>
              )}
            </Command.List>
          </Command>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}
```

Mount it once in the root layout so the shortcut works on every page:

```tsx
// app/layout.tsx
import "./globals.css";
import type { ReactNode } from "react";
import { CommandPalette } from "@/components/CommandPalette";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body className="bg-white text-zinc-900">
        {children}
        <CommandPalette />
      </body>
    </html>
  );
}
```

And a small search endpoint to stand in for your database:

```ts
// app/api/search/route.ts
import { NextResponse } from "next/server";

const docs = [
  { slug: "getting-started", title: "Getting started" },
  { slug: "billing-faq", title: "Billing FAQ" },
  { slug: "api-keys", title: "Creating API keys" },
  { slug: "webhooks", title: "Webhooks and retries" },
];

export async function GET(request: Request) {
  const q = new URL(request.url).searchParams.get("q")?.toLowerCase() ?? "";
  await new Promise((r) => setTimeout(r, 300)); // pretend this is a database
  const hits = docs.filter((d) => d.title.toLowerCase().includes(q));
  return NextResponse.json(hits);
}
```

## What each part fixes

### The shortcut

`e.preventDefault()` matters on Windows and Linux, where Ctrl+K belongs to the browser's search bar. Checking `metaKey || ctrlKey` covers both platforms with one listener. In the test, Ctrl+K and Meta+K both opened the palette, pressing the shortcut again closed it, and it also opened while focus was inside a text field on the page. If you'd rather not steal the shortcut from inputs, return early when `e.target` is an input or textarea.

### A title nobody sees

Radix expects a `Dialog.Title` inside the content, because that's what names the dialog for screen readers. A palette has no visible heading, so the title and description are there with `sr-only`. In the test, the dialog's `aria-labelledby` pointed at the "Command menu" title. The `label` prop on `Command` is a different thing: it labels the search list, not the dialog. shadcn's `CommandDialog` does the same trick with a hidden `DialogTitle` and `DialogDescription`. I read its registry source to check.

### Focus goes back where it was

Without a `Dialog.Trigger`, Radix has nothing to return focus to when the palette closes. In my first run, focus didn't go back to the text field after Escape, so a user typing there had to click back into it. The fix is to remember `document.activeElement` when the shortcut fires and focus it again in `onCloseAutoFocus`. After the fix, Escape put focus back in the field the user came from. The `isConnected` check skips this after a navigation, when the old element is gone.

### Closing and resetting together

The first version reset the search text in `onOpenChange` only. Radix calls that for Escape and clicks outside, but not when my own code closed the dialog after a selection, so the old query was still in the box the next time it opened. Routing every close through `changeOpen` fixed it: after picking a result and reopening, the input was empty.

### Server results and cmdk's filter

cmdk filters items on the client by their `value` and `keywords`. That works for the static pages: typing "acct" matched both Settings (keyword "account") and Billing, because cmdk's matching is fuzzy. But server results are already filtered by your API, and cmdk may hide some of them when its own matching disagrees. `forceMount` on those items tells cmdk to always show them. cmdk also tracks each item by its `value`, so keep values unique. Prefixing them per group, like `page ${p.label}` and `doc ${d.slug}`, keeps a page and a doc with the same name apart.

The search waits 200 ms after the last keystroke, and the effect's cleanup aborts the previous request, so a slow answer for "bi" can't land on top of the answer for "bil". In the test, typing "bil" showed Billing at once and added "Billing FAQ" from the server a moment later (the 200 ms wait plus the endpoint's fake 300 ms delay). Arrow Down moved the highlight to it and Enter went to `/docs/billing-faq`.

### Styling cmdk with Tailwind

cmdk renders with no styles. The highlighted item carries `data-selected="true"`, so Tailwind's `data-[selected=true]:bg-zinc-100` styles it without any state of your own. Group headings get a `cmdk-group-heading` attribute if you want to style those too.

## Checks from the test run

- Ctrl+K opened the dialog with the search input already focused.
- Escape closed it. So did a second Ctrl+K.
- Typing "zzzz" showed "No results."
- At 390 px wide, the panel was 359 px wide and nothing scrolled sideways.
- The desktop run logged no console errors or page errors.

I haven't tested it with VoiceOver or NVDA, only the ARIA attributes in the DOM.

## Should you use shadcn's Command instead?

If you already use shadcn/ui, yes, add its Command component and keep the shortcut, focus and `forceMount` parts from above. It's the same cmdk and Radix underneath with your theme already applied. If you don't use shadcn, the component above is about 140 lines with two small dependencies, and nothing is hidden in a wrapper. I didn't run the shadcn install for this post.

## Want it already designed?

The [Command-K Dashboard](/components/dashboard-saas) on qodesign is a whole SaaS dashboard built around a ⌘K palette like this one. There's a [Next.js guide for it](/react/command-k-dashboard). If you're adding the palette to a v0 or Lovable app, [this is how I keep generated apps from looking generated](/blog/make-v0-app-not-look-ai-generated), and the free pieces with full source are at [/free](/free).

## FAQ

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

Add one `keydown` listener on `document` that checks `e.key` for "k" and `e.metaKey || e.ctrlKey`, calls `e.preventDefault()` and toggles the open state. Mount the component once in your root layout so it works on every page.

### What is cmdk?

cmdk is an unstyled React component for command menus. It handles the search input, filtering, keyboard navigation, groups and empty states. shadcn/ui's Command component is built on it.

### Why are my async results not showing in cmdk?

cmdk runs its own filter over every item. If your server already filtered the results, add `forceMount` to those items, or set `shouldFilter={false}` on `Command` when every item comes from the server.

### Does a command palette need a dialog title?

Yes, for screen readers. Put a `Dialog.Title` with the `sr-only` class inside the dialog content. It names the dialog without showing a heading.
