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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/command-palette.jsonPrompt
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 ordering —
recentIdsis resolved against the livegroupsdata 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 notabIndex. - 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
useSyncExternalStorereadingnavigator, 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
onKeyDownand 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 disablingbindShortcutnever touches the in-dialog keyboard behavior. - Scroll lock counted on
document.body— the lock count and the savedoverflow/paddingRightlive in data attributes onbody, 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 compensatingpaddingRightis measured across theoverflow: hiddenwrite rather than guessed frominnerWidth - clientWidth, which over-pads on pages usingscrollbar-gutter: stableand shifts content left instead of holding it still.
Floating Navbar
A header that hides on scroll-down and reappears on scroll-up, giving long reading surfaces the vertical space back without losing the nav.
Sticky Header
An in-flow page header that stays pinned to the top and reacts to scroll — shrinking its height, gaining a blurred glass surface, or hiding on scroll-down.