Skip to main content
Back to Blog
Tutorials
3 min read
November 9, 2024

How to Build a Command Palette with cmdk in React

Add a command palette to your app with cmdk. Fast fuzzy search, keyboard navigation, and grouped commands.

Ryel Banfield

Founder & Lead Developer

A command palette (Cmd+K) gives power users instant access to navigation, search, and actions. The cmdk library makes this easy to build.

Step 1: Install cmdk

pnpm add cmdk

Step 2: Basic Command Palette

"use client";

import { useState, useEffect } from "react";
import { Command } from "cmdk";

export function CommandPalette() {
  const [open, setOpen] = useState(false);

  // Toggle with Cmd+K
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((prev) => !prev);
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, []);

  if (!open) return null;

  return (
    <div className="fixed inset-0 z-50">
      <div
        className="absolute inset-0 bg-black/50"
        onClick={() => setOpen(false)}
      />
      <div className="relative mx-auto mt-[20vh] max-w-lg">
        <Command className="overflow-hidden rounded-xl border bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
          <Command.Input
            placeholder="Type a command or search..."
            className="w-full border-b px-4 py-3 text-sm outline-none dark:border-gray-700 dark:bg-gray-900"
          />
          <Command.List className="max-h-80 overflow-y-auto p-2">
            <Command.Empty className="py-6 text-center text-sm text-gray-500">
              No results found.
            </Command.Empty>

            <Command.Group heading="Navigation" className="mb-2">
              <CommandItem onSelect={() => navigate("/")}>
                Home
              </CommandItem>
              <CommandItem onSelect={() => navigate("/services")}>
                Services
              </CommandItem>
              <CommandItem onSelect={() => navigate("/blog")}>
                Blog
              </CommandItem>
              <CommandItem onSelect={() => navigate("/pricing")}>
                Pricing
              </CommandItem>
              <CommandItem onSelect={() => navigate("/contact")}>
                Contact
              </CommandItem>
            </Command.Group>

            <Command.Group heading="Actions" className="mb-2">
              <CommandItem onSelect={() => toggleTheme()}>
                Toggle Dark Mode
              </CommandItem>
              <CommandItem onSelect={() => copyUrl()}>
                Copy Page URL
              </CommandItem>
            </Command.Group>
          </Command.List>
        </Command>
      </div>
    </div>
  );
}

function CommandItem({
  children,
  onSelect,
}: {
  children: React.ReactNode;
  onSelect: () => void;
}) {
  return (
    <Command.Item
      onSelect={onSelect}
      className="flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm text-gray-700 aria-selected:bg-blue-50 aria-selected:text-blue-600 dark:text-gray-300 dark:aria-selected:bg-blue-900/20"
    >
      {children}
    </Command.Item>
  );
}

Step 3: Style the Command Groups

/* Add to globals.css */
[cmdk-group-heading] {
  padding: 0.5rem 0.75rem;
  font-size: 0.75rem;
  font-weight: 500;
  color: rgb(107 114 128);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

Step 4: Add Icons

<Command.Group heading="Navigation">
  <CommandItem onSelect={() => navigate("/")}>
    <HomeIcon className="h-4 w-4" />
    <span>Home</span>
  </CommandItem>
  <CommandItem onSelect={() => navigate("/services")}>
    <BriefcaseIcon className="h-4 w-4" />
    <span>Services</span>
  </CommandItem>
  <CommandItem onSelect={() => navigate("/blog")}>
    <BookIcon className="h-4 w-4" />
    <span>Blog</span>
  </CommandItem>
  <CommandItem onSelect={() => navigate("/contact")}>
    <MailIcon className="h-4 w-4" />
    <span>Contact</span>
  </CommandItem>
</Command.Group>

Step 5: Add Keyboard Shortcuts Display

function CommandItem({
  children,
  shortcut,
  onSelect,
}: {
  children: React.ReactNode;
  shortcut?: string;
  onSelect: () => void;
}) {
  return (
    <Command.Item
      onSelect={onSelect}
      className="flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm aria-selected:bg-blue-50 dark:aria-selected:bg-blue-900/20"
    >
      <div className="flex items-center gap-3">{children}</div>
      {shortcut && (
        <kbd className="rounded border px-1.5 py-0.5 text-xs text-gray-400 dark:border-gray-600">
          {shortcut}
        </kbd>
      )}
    </Command.Item>
  );
}

// Usage
<CommandItem onSelect={toggleTheme} shortcut="⌘D">
  Toggle Dark Mode
</CommandItem>

Step 6: Dynamic Search Results

"use client";

import { useState, useEffect } from "react";
import { Command } from "cmdk";

export function CommandPalette() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (!query.trim()) {
      setSearchResults([]);
      return;
    }

    setLoading(true);
    const timeout = setTimeout(async () => {
      const results = await searchContent(query);
      setSearchResults(results);
      setLoading(false);
    }, 200);

    return () => clearTimeout(timeout);
  }, [query]);

  return (
    <Command shouldFilter={false}>
      <Command.Input
        value={query}
        onValueChange={setQuery}
        placeholder="Search..."
      />
      <Command.List>
        {loading && (
          <Command.Loading>
            <div className="py-4 text-center text-sm text-gray-500">
              Searching...
            </div>
          </Command.Loading>
        )}

        {!loading && query && searchResults.length === 0 && (
          <Command.Empty>No results found.</Command.Empty>
        )}

        {searchResults.length > 0 && (
          <Command.Group heading="Search Results">
            {searchResults.map((result) => (
              <Command.Item
                key={result.slug}
                value={result.title}
                onSelect={() => navigate(result.slug)}
              >
                <span>{result.title}</span>
                <span className="text-xs text-gray-400">{result.category}</span>
              </Command.Item>
            ))}
          </Command.Group>
        )}

        {/* Static items always shown */}
        {!query && (
          <>
            <Command.Group heading="Quick Links">
              {/* navigation items */}
            </Command.Group>
            <Command.Group heading="Actions">
              {/* action items */}
            </Command.Group>
          </>
        )}
      </Command.List>
    </Command>
  );
}

Step 7: Nested Commands (Sub-menus)

const [pages, setPages] = useState<string[]>([]);
const activePage = pages[pages.length - 1];

<Command
  onKeyDown={(e) => {
    if (e.key === "Backspace" && !query) {
      setPages((prev) => prev.slice(0, -1));
    }
  }}
>
  {!activePage && (
    <Command.Group heading="Actions">
      <Command.Item onSelect={() => setPages(["theme"])}>
        Change Theme...
      </Command.Item>
    </Command.Group>
  )}

  {activePage === "theme" && (
    <Command.Group heading="Theme">
      <Command.Item onSelect={() => setTheme("light")}>Light</Command.Item>
      <Command.Item onSelect={() => setTheme("dark")}>Dark</Command.Item>
      <Command.Item onSelect={() => setTheme("system")}>System</Command.Item>
    </Command.Group>
  )}
</Command>

Step 8: Using with Dialog

For better accessibility, wrap in a dialog component:

import * as Dialog from "@radix-ui/react-dialog";
import { Command } from "cmdk";

<Dialog.Root open={open} onOpenChange={setOpen}>
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/50" />
    <Dialog.Content className="fixed left-1/2 top-[20vh] -translate-x-1/2">
      <Command>
        {/* command palette content */}
      </Command>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Need Advanced UI Features?

We build web applications with power-user features like command palettes, keyboard shortcuts, and advanced navigation. Contact us for a consultation.

command palettecmdkReactkeyboard shortcutstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles