navigation
Command Palette
A searchable ⌘K overlay for jumping to sessions or running quick actions.
Quick actions
Recent
↑↓SelectOpenEscClose
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { CornerDownLeft, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CommandPaletteItem {
id: string;
label: string;
icon?: React.ComponentType<{ className?: string }>;
meta?: string;
}
export interface CommandPaletteGroup {
label: string;
items: CommandPaletteItem[];
}
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
groups: CommandPaletteGroup[];
placeholder?: string;
emptyLabel?: string;
onSelect?: (item: CommandPaletteItem) => void;
className?: string;
}
export function CommandPalette({
open,
onOpenChange,
groups,
placeholder,
emptyLabel,
onSelect,
className,
}: CommandPaletteProps) {
function close() {
onOpenChange(false);
}
return (
<AnimatePresence>
{open && (
<div className={cn("fixed inset-0 z-50 flex justify-center px-4 pt-[12vh]", className)}>
<motion.div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={close}
aria-hidden
/>
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.15, ease: "easeOut" }}
className="relative z-10 w-full max-w-lg"
>
<CommandPaletteWindow
groups={groups}
placeholder={placeholder}
emptyLabel={emptyLabel}
autoFocus
onSelect={(item) => {
onSelect?.(item);
close();
}}
onClose={close}
/>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
export interface CommandPaletteWindowProps {
groups: CommandPaletteGroup[];
placeholder?: string;
emptyLabel?: string;
onSelect?: (item: CommandPaletteItem) => void;
onClose?: () => void;
autoFocus?: boolean;
className?: string;
}
/** The palette's search input, result list, and key-hint footer, without the modal chrome (backdrop, fixed positioning). Used by `CommandPalette` for the real overlay, and reusable on its own wherever a static, non-modal preview of the window is useful. */
export function CommandPaletteWindow({
groups,
placeholder = "Search or run a command...",
emptyLabel = "No matches",
onSelect,
onClose,
autoFocus = false,
className,
}: CommandPaletteWindowProps) {
const listId = React.useId();
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const visibleGroups = React.useMemo(() => filterGroups(groups, query), [groups, query]);
const flatItems = React.useMemo(() => visibleGroups.flatMap((g) => g.items), [visibleGroups]);
React.useEffect(() => {
if (autoFocus) {
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}
}, [autoFocus]);
const prevActiveIndexRef = React.useRef<number | null>(null);
React.useEffect(() => {
const prevActiveIndex = prevActiveIndexRef.current;
prevActiveIndexRef.current = activeIndex;
if (prevActiveIndex === null || prevActiveIndex === activeIndex) return;
const row = listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
row?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
function commit(item: CommandPaletteItem | undefined) {
if (!item) return;
onSelect?.(item);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose?.();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatItems.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
commit(flatItems[activeIndex]);
}
}
return (
<div
role="dialog"
aria-modal="true"
aria-label="Command palette"
onKeyDown={handleKeyDown}
className={cn(
"flex h-fit max-h-[70vh] w-full flex-col overflow-hidden rounded-2xl border bg-popover shadow-2xl",
className
)}
>
<div className="flex items-center gap-2.5 border-b px-4 py-3.5">
<Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={flatItems[activeIndex] ? `${listId}-item-${flatItems[activeIndex].id}` : undefined}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
placeholder={placeholder}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
<div id={listId} role="listbox" ref={listRef} className="min-h-0 flex-1 overflow-y-auto p-2">
{flatItems.length === 0 ? (
<p className="px-3 py-8 text-center text-sm text-muted-foreground">{emptyLabel}</p>
) : (
visibleGroups.map((group) => (
<div key={group.label} className="mb-2 last:mb-0">
<p className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{group.label}</p>
{group.items.map((item) => {
const index = flatItems.indexOf(item);
const active = index === activeIndex;
const Icon = item.icon;
return (
<button
key={item.id}
id={`${listId}-item-${item.id}`}
data-index={index}
role="option"
aria-selected={active}
type="button"
onMouseEnter={() => setActiveIndex(index)}
onClick={() => commit(item)}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm transition-colors",
active ? "bg-accent text-accent-foreground" : "text-foreground"
)}
>
{Icon && <Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta && <span className="shrink-0 text-xs text-muted-foreground">{item.meta}</span>}
</button>
);
})}
</div>
))
)}
</div>
<div className="flex items-center gap-3 border-t px-4 py-2.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
Select
</span>
<span className="flex items-center gap-1.5">
<Kbd>
<CornerDownLeft className="size-3" />
</Kbd>
Open
</span>
<span className="ml-auto flex items-center gap-1.5">
<Kbd>Esc</Kbd>
Close
</span>
</div>
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border bg-muted px-1 font-sans text-[10px] font-medium text-muted-foreground">
{children}
</kbd>
);
}
function filterGroups(groups: CommandPaletteGroup[], query: string): CommandPaletteGroup[] {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => item.label.toLowerCase().includes(trimmed)),
}))
.filter((group) => group.items.length > 0);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Command Palette
## Summary
A search-first overlay for jumping to or launching something — a session, a document, a command — without leaving the keyboard. A single input filters a short list of quick actions and recent items; arrow keys move the highlight, Enter commits it.
## When to use
- Switching between many similar items (sessions, chats, files, projects) faster than a sidebar or list view allows.
- Surfacing a small set of global actions ("New session", "New chat") alongside recent history in one place.
- Any surface that already has a keyboard-shortcut culture (⌘K launchers, IDE-style command bars).
## When not to use
- A handful of items (fewer than ~6) that fit comfortably in a visible list or dropdown — the overlay adds a step for no benefit.
- Destructive or multi-step actions (delete, bulk export). The palette commits on a single Enter; anything needing confirmation belongs in its own flow.
- As the only way to reach a primary action. Always keep a visible trigger (button, menu item) alongside the keyboard shortcut — the shortcut is an accelerator, not the sole path.
## Anatomy
- Backdrop (dims and blurs the page behind the palette, closes on click).
- Search input with a leading search icon and a close (×) button.
- Scrollable result list, grouped into labeled sections (e.g. "Quick actions", "Recent").
- Each row: optional leading icon, label (truncates), optional trailing meta text (timestamp, source).
- Footer hint bar: keyboard legend for select / open / close.
## Behavior
- Opens via an explicit trigger (button) and/or a global shortcut (⌘K / Ctrl+K); the trigger must remain visible even where the shortcut exists.
- The search input autofocuses the moment the palette opens.
- With an empty query, show the full grouped list (quick actions first, then recent items) — don't force typing before anything is visible.
- Typing filters items by label across all groups in place; groups with no matches collapse out entirely rather than showing an empty header.
- Arrow Up/Down move the highlighted row, clamped at the first/last item (no wraparound) so repeated key-holds don't overshoot silently.
- Hovering a row also updates the highlight, kept in sync with keyboard navigation.
- Enter commits the highlighted row; clicking a row commits it directly.
- Escape, a backdrop click, or the × button close the palette without committing.
- Closing resets the query and highlight so the next open starts fresh.
- The highlighted row auto-scrolls into view as it changes, so keyboard navigation never drifts off-screen in a long list.
## Content guidelines
- Row labels are the item's real name (a session title, a file name) — never a truncated ID or slug.
- Meta text is short and secondary: a relative timestamp ("Just now", "Last hour") or a compact source tag ("PR #52"). Never wrap it.
- Group labels are short nouns ("Quick actions", "Recent") — not instructions.
- The empty-results message is a plain statement ("No matches"), not a call to action.
## Accessibility
- Root overlay uses `role="dialog"` with `aria-modal="true"` and a descriptive `aria-label`.
- The input uses `role="combobox"` with `aria-expanded` and `aria-controls` pointing at the result list, plus `aria-activedescendant` tracking the highlighted row's id.
- The result list uses `role="listbox"`; each row is `role="option"` with `aria-selected` reflecting the current highlight.
- All interaction must work from the keyboard alone: focus starts in the input, arrow keys and Enter never require a pointer.
- Respect `prefers-reduced-motion` by skipping the backdrop fade and panel scale/slide.
## Related patterns
- None. This is a standalone navigation/launcher pattern, not part of an agent-status sequence.