Quick actions
Recent
↑↓SelectOpenEscClose
"use client";
import * as React from "react";
import { createPortal } from "react-dom";
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;
/** Extra searchable text (e.g. a description) — matched by the filter but not rendered. */
keywords?: 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) {
// Portal to <body>: an ancestor with backdrop-filter, filter or transform
// (e.g. a blurred sticky header) would otherwise become the containing
// block for this `fixed` overlay and clip it to that ancestor's box.
const mounted = React.useSyncExternalStore(
subscribeNoop,
() => true,
() => false
);
function close() {
onOpenChange(false);
}
if (!mounted) return null;
return createPortal(
<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>,
document.body
);
}
function subscribeNoop() {
return () => {};
}
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,
// Label matches rank above matches found only in keywords.
items: [
...group.items.filter((item) => item.label.toLowerCase().includes(trimmed)),
...group.items.filter(
(item) =>
!item.label.toLowerCase().includes(trimmed) &&
!!item.keywords?.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 — and by optional hidden keywords such as a description — across all groups in place; label matches rank above keyword-only matches, and 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
- **Quick Chat Dock** — Use it instead when people should be able to ask the AI from anywhere, not just jump to commands.
- **Instruction Tool Insert** — Use it instead when the picked item is inserted into written instructions rather than run or opened.
- **Describe Filters** — Use it instead when people want to narrow a set by intent rather than jump to a known item.
- **Natural Language Query** — Use it instead when people are asking a question of data rather than looking for a command.
Do’s and don’ts
Do use it for
- 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).
Don’t use it for
- 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.
Related patterns
Quick Chat Dock
Use it instead when people should be able to ask the AI from anywhere, not just jump to commands.
Instruction Tool Insert
Use it instead when the picked item is inserted into written instructions rather than run or opened.
Describe Filters
Use it instead when people want to narrow a set by intent rather than jump to a known item.
Natural Language Query
Use it instead when people are asking a question of data rather than looking for a command.