Navigation

Command Palette

A ⌘K-style command palette — portal dialog, fuzzy subsequence search, grouped commands, recent items and full keyboard navigation.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { createPortal } from "react-dom"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * The entrance animation ships with the component: React 19 hoists <style href>
 * into head and dedupes by href, so N palettes on a page still mean one keyframes block.
 */
const KEYFRAMES = `@keyframes zg-cmdk-overlay-in{from{opacity:0}to{opacity:1}}
@keyframes zg-cmdk-panel-in{from{opacity:0;transform:translateY(-8px) scale(0.98)}to{opacity:1;transform:none}}`

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/command-palette.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "CommandPalette" component (uses
react-dom's createPortal and lucide-react for icons).

Contract
- export function CommandPalette({ open, onOpenChange, groups, placeholder,
  emptyText, recentIds, bindShortcut, footer, className }: CommandPaletteProps).
- CommandPaletteProps: open: boolean; onOpenChange: (open: boolean) => void;
  groups: CommandGroup[]; placeholder?: string (default "Type a command or
  search…"); emptyText?: string (default "No results found."); recentIds?:
  string[]; bindShortcut?: boolean (default true); footer?: React.ReactNode;
  className?: string.
- CommandGroup = { heading: string; items: CommandItem[] }.
- CommandItem = { id: string; label: string; hint?: string; icon?:
  React.ReactNode; keywords?: string[]; shortcut?: string; disabled?: boolean;
  onSelect: () => void }.
- Fully controlled: no internal open state, no uncontrolled fallback.

Behavior
- Renders nothing while `open` is false; while true, portals a dialog to
  document.body (guard the portal behind a client-only check so SSR never
  touches document, and the client's first paint matches the server's before
  swapping in).
- Search: match `label` plus joined `keywords` with a case-insensitive
  subsequence match (every query character must appear in order, not
  necessarily contiguous) — not substring, not Levenshtein.
- Groups whose items are all filtered out are dropped entirely (never render
  an empty heading).
- When the query is empty and `recentIds` is non-empty, resolve those ids
  against every group's items (preserving `recentIds` order, dropping missing
  ids) and prepend a synthetic "Recent" group ahead of the regular groups.
  Recent items disappear the instant the user types anything.
- Keyboard: ArrowUp/ArrowDown move the highlighted option among *visible,
  non-disabled* entries only, wrapping at both ends. Enter activates the
  highlighted entry's `onSelect` then closes. Escape closes. Clicking an
  option (if not disabled) does the same as Enter. Clicking the overlay
  closes; clicking inside the panel does not (stopPropagation).
- All of that (Escape / Arrow / Enter / Tab) lives on the panel's own React
  onKeyDown and Escape calls stopPropagation — NOT a window keydown listener.
  A window listener cannot tell which layer is on top, and this palette is
  routinely summoned with ⌘K while a drawer or a modal popover is already
  open: one Escape press is then received by both handlers and closes both
  layers, throwing the user out of the form they were filling. On the panel
  with stopPropagation only the innermost layer sees it. Focus is already in
  the search input when the panel opens, so a panel-level handler reliably
  receives the key.
- ⌘K / Ctrl+K toggles `open` via a global keydown listener, but only when
  `bindShortcut` is true; the listener attaches/detaches with that prop so
  turning it off truly disables it (consumers wire their own trigger button
  instead).
- Reset the search query to "" every time `open` flips from false to true —
  never reopen with a stale filter.
- Opening the dialog: capture `document.activeElement` before moving focus,
  then focus the search input. Closing / unmounting: return focus to the
  captured element.
- Body scroll lock while open — keep the reentrancy count AND the pre-lock
  snapshot on `document.body` as data attributes
  (`body.dataset.zyScrollLocks`, `.zyScrollLockOverflow`,
  `.zyScrollLockPadding`), never in module-level variables. The 0 → 1 edge
  snapshots body's current inline `overflow` / `paddingRight` (the current
  values, not a hardcoded "") and freezes; later locks only increment; only
  the 1 → 0 release restores the snapshot and deletes all three attributes.
  Module scope is not enough: every component here is installed as its own
  copy, so one page runs several independent copies of this same lock (this
  palette, a drawer, a modal popover), each with a private counter blind to
  the others. Nest two and the outer one restores "" on close while the inner
  one later writes back the "hidden" it recorded as the original — the page is
  frozen until a reload with no overlay left on screen to explain it. A body
  attribute is the one namespace independent copies already share; keep the
  three names byte-identical wherever this code is pasted.
- Scrollbar compensation is MEASURED, not predicted: read
  `document.documentElement.clientWidth`, set `overflow: hidden`, read it
  again, and add the positive difference to body's computed `paddingRight`.
  The `innerWidth - clientWidth` shortcut is wrong on any page with
  `scrollbar-gutter: stable` — the gutter is permanent, no width is reclaimed,
  and padding ~15px anyway shifts the page LEFT behind the scrim as the
  palette opens. The measurement yields 0 under macOS overlay scrollbars, so a
  Mac-only test proves nothing about this branch.
- Reduce all of this to two lifecycle effects keyed on `open` (focus +
  scroll-lock) plus exactly one keydown-listener effect, gated on
  `bindShortcut`, for the global ⌘K toggle — that one genuinely has to be
  global, since it must fire while the palette is closed. Every listener/lock
  removes itself in its cleanup function.

Rendering & styling
- Semantic tokens only: bg-background/80 + backdrop-blur-sm overlay,
  bg-popover/text-popover-foreground panel, bg-accent/text-accent-foreground
  for the highlighted option, bg-muted for <kbd> hint chips, border/shadow-2xl
  for the panel edge. No hardcoded colors.
- Panel: role="dialog" aria-modal="true" aria-label; search input:
  role="combobox" aria-expanded="true" aria-controls={listboxId}
  aria-activedescendant={currently highlighted option's id or undefined};
  list container: role="listbox"; each row: role="option" aria-selected
  aria-disabled. Options are not native tab stops — real DOM focus stays on
  the input throughout, exactly like the ARIA combobox-listbox pattern.
- aria-modal="true" is a promise that the page behind the panel is unreachable,
  so Tab must be trapped: collect the panel's focusable descendants and wrap from
  last to first (and back with Shift+Tab); with only the search field focusable,
  loop straight back to it. Without the trap, focus silently leaves for the
  background page while the palette stays visible.
- Each group wrapper between the listbox and its options needs role="group" plus
  aria-labelledby pointing at its heading — a roleless div in between exposes a
  listbox that owns no options at all.
- Every option row: optional icon (normalize any consumer SVG to size-4),
  label (`truncate`, never wraps), optional `hint` in muted text, optional
  `shortcut` rendered as a <kbd> chip — all in one flex row so long labels
  ellipsize instead of pushing the shortcut off-panel.
- A default footer (used when `footer` is omitted) shows ↑↓ Navigate, ↵
  Select, Esc Close, and — only when `bindShortcut` is true — a ⌘/Ctrl + K
  Toggle hint using the modifier label described below.
- Ship two @keyframes (overlay fade, panel pop) via a React 19 hoisted
  <style href precedence="medium"> tag; wrap every animation class in
  motion-reduce:[animation:none].

Customization levers
- Empty state copy: `emptyText` — swap per surface ("No files found." vs "No
  commands match.").
- Density: shrink the option row's `py-2` and the input's `h-12` together for
  a more compact palette; keep them proportional so hit targets stay usable.
- Footer: pass your own `footer` node (e.g. a "Powered by ⌘K" brand strip or
  nothing at all) to fully replace the default keyboard-hint row.
- Global shortcut: set `bindShortcut={false}` when the host app already owns
  ⌘K for something else, and drive `open` from your own trigger.
- Recent surfacing: `recentIds` is just an ordered id list — swap in the
  user's last N visited command ids from wherever you persist them (memory,
  localStorage, a query param).
- Group order and headings are entirely caller-defined; the palette never
  reorders or renames them beyond prepending "Recent".

Concepts

  • Subsequence fuzzy match — the query's characters must appear in order inside label + keywords, but not contiguously; typing "cpl" still finds "Command Palette".
  • Recent-first orderingrecentIds is resolved against the live groups data every render, so a "Recent" group never goes stale; it vanishes as soon as the query is non-empty.
  • combobox-listbox pattern — real DOM focus never leaves the search input; the highlighted option is only ever communicated via aria-activedescendant, which is why options carry no tabIndex.
  • Focus restore — the element focused right before the palette opened is captured once and refocused on close, so triggering it from a button (or another app-level command) never strands keyboard focus.
  • Hydration-safe modifier hint — the ⌘ vs Ctrl label is resolved via useSyncExternalStore reading navigator, not during the initial render, so server and client markup always agree before the correct label swaps in.
  • Innermost-layer Escape — Escape (and Arrow/Enter/Tab) is handled on the panel's own onKeyDown and stops propagation, so a palette summoned on top of a drawer closes only itself. A window listener has no notion of "which layer is on top": both handlers would fire on the same press and take the underlying layer down with the palette. Only the ⌘K toggle stays global, because it has to work while the palette is closed — which is also why disabling bindShortcut never touches the in-dialog keyboard behavior.
  • Scroll lock counted on document.body — the lock count and the saved overflow / paddingRight live in data attributes on body, not in module-level variables, because every component here is installed as its own copy: a drawer or a modal popover on the same page runs a different copy of the identical lock with its own private counter. Two blind counters restore each other's values — the first closes and writes back "", the second closes and writes back the "hidden" it believed was the original — and the page never scrolls again. The compensating paddingRight is measured across the overflow: hidden write rather than guessed from innerWidth - clientWidth, which over-pads on pages using scrollbar-gutter: stable and shifts content left instead of holding it still.

On This Page