Skip to content
modalWeek 02Added

Command Palette

A ⌘K / Ctrl+K command menu with ranked search, grouped results, keyboard navigation, match highlighting, shortcuts, disabled and loading states. Built on the native <dialog> element.

Live preview

Press ⌘K or Ctrl K anywhere on this page. Try typing “bill” or “nwp”.

Actions
New projectStart from a blank canvas
Invite teammates
Export as PDF
Archive workspaceOnly owners can do this
Navigation
Go to dashboard
Go to billing
Open settings
Theme
Switch to light theme
Switch to dark theme
Help
Search documentation
Contact support

11 results

  • ⌘K / Ctrl+K
  • arrow keys
  • Enter
  • Esc clears then closes
  • backdrop click
  • disabled item
  • loading
  • no results

Quality checks

  • Chromium · light passed
  • Chromium · dark passed
  • Firefox · light passed
  • Firefox · dark passed
  • WebKit · light passed
  • WebKit · dark passed

Each run loads the page at 375, 768 and 1280 px (no horizontal overflow), fails on any console error and runs an axe WCAG 2.1 AA scan. Accessibility violations across all runs: 0 · last run .

Code

import { useState } from "react";
import { CommandPalette, type CommandItem } from "@/components/ui/command-palette";

const items: CommandItem[] = [
  { id: "new", group: "Actions", label: "New project", shortcut: ["⌘", "N"], onSelect: () => createProject() },
  { id: "billing", group: "Navigation", label: "Go to billing", keywords: ["invoice", "plan"], onSelect: () => router.push("/billing") },
  { id: "archive", group: "Actions", label: "Archive workspace", disabled: true, onSelect: () => {} },
];

export function AppShell() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Search…</button>
      {/* ⌘K / Ctrl+K also toggles it */}
      <CommandPalette open={open} onOpenChange={setOpen} items={items} />
    </>
  );
}

Props

PropTypeDefaultDescription
openboolean—Whether the palette is open (controlled).
onOpenChange(open: boolean) => void—Called when it asks to open or close (hotkey, Esc, backdrop, select).
itemsCommandItem[]—{ id, label, group?, description?, keywords?, shortcut?, icon?, disabled?, onSelect }.
placeholderstring"Type a command or search…"Search input placeholder.
emptyMessagestring"No results found."Shown when nothing matches.
hotkeystring | false"k"Letter that toggles it with ⌘/Ctrl. false disables it.
loadingbooleanfalseShow skeleton rows and set aria-busy.
labelstring"Command palette"Accessible name of the dialog.
closeOnSelectbooleantrueClose after an item runs.

Build prompt

The final prompt used to generate this component.

Build a reusable React + TypeScript + Tailwind v4 "CommandPalette" (⌘K menu) for Next.js App Router.

- Controlled: open / onOpenChange. Typed items: { id, label, group?, description?, keywords?, shortcut?, icon?, disabled?, onSelect }. Props: placeholder, emptyMessage, hotkey (⌘/Ctrl + letter, default "k", false = off), loading, label, closeOnSelect.
- Use native <dialog> + showModal() for focus trap, Escape, top layer and inert page. Close on backdrop click, restore focus to the opener, lock page scroll.
- Search ranking: label starts with query > word starts with it > contains > keyword > description > loose in-order match ("nwp" → "New project"). Keep group order, sort inside groups, highlight matches with <mark>.
- A11y: input is a combobox (aria-expanded, aria-controls, aria-autocomplete="list", aria-activedescendant) for a listbox of options in role="group" sections with headings. Live region announces the result count. Disabled items get aria-disabled and are skipped.
- Keys: ArrowUp/Down wrap, Home/End, Enter runs, first Esc clears the query, second closes. Keep active option in view; mousedown must not steal focus.
- States: loading skeleton (aria-busy), empty message, disabled, <kbd> shortcuts, footer hints, @starting-style entry animation that respects reduced motion.
- Responsive (max-w-xl, list capped at 60vh), light/dark tokens, no dependencies.
Demo: Actions / Navigation / Theme / Help groups with icons, shortcuts, one disabled item, a search-style trigger and a "Ran: …" readout.

Build notes

Problems found while building and testing it, how they were fixed, and the decisions that shaped it.

  • Group headings like "Go to" and item ids with spaces produced invalid element ids, which breaks aria-activedescendant and aria-labelledby. Ids are now sanitised before use.
  • Escape is owned by the native dialog's cancel event. The search box intercepts the first press so it clears the query, and only the second press closes; checked in Chromium, Firefox and WebKit.
  • Option rows cancel mousedown so clicking a result never takes focus away from the search box, so arrow keys keep working after mouse use.
  • The hotkey listens for both ⌘ and Ctrl so it works on macOS and Windows; the tests press Meta+K in WebKit and Ctrl+K elsewhere.