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

5 min read

TL;DR 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.

On this page
  1. Install
  2. The CommandPalette component
  3. What each part fixes
  4. The shortcut
  5. A title nobody sees
  6. Focus goes back where it was
  7. Closing and resetting together
  8. Server results and cmdk's filter
  9. Styling cmdk with Tailwind
  10. Checks from the test run
  11. Should you use shadcn's Command instead?
  12. Want it already designed?
  13. FAQ
  14. How do I open a command palette with Cmd+K in React?
  15. What is cmdk?
  16. Why are my async results not showing in cmdk?
  17. Does a command palette need a dialog title?

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, 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.

Command-K Dashboard running live. A whole SaaS app you drive from ⌘K: rows stretch into pages, coins fly into the total.

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 on qodesign is a whole SaaS dashboard built around a ⌘K palette like this one. There's a Next.js guide for it. If you're adding the palette to a v0 or Lovable app, this is how I keep generated apps from looking generated, and the free pieces with full source are at /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.