ui
Command palette
Grouped, fuzzy-matched ⌘K palette with nested pages, recents and shortcut hints.
npx shadcn@latest add @hextor/command-paletteSource
"use client";
import * as React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { ArrowLeft, ChevronRight, SearchX } from "lucide-react";
import { cn } from "@/registry/hextor/lib/utils";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandInput,
CommandShortcut,
} from "@/components/ui/command";
import { useFuzzySearch } from "@/registry/hextor/hooks/use-fuzzy-search";
import { useRecentItems } from "@/registry/hextor/hooks/use-recent-items";
import {
resolveLabels,
type CommandPaletteLabels,
} from "@/registry/hextor/ui/command-palette-labels";
export interface CommandPaletteAction {
id: string;
label: string;
subtitle?: string;
icon?: LucideIcon;
/** Groups the browse view and (while filtering) the ranked results. */
group: string;
/** Extra terms to match against that never render — aliases, synonyms. */
keywords?: string[];
/** Display parts for the trailing hint, e.g. `["⌘", "K"]`. Cosmetic only. */
shortcut?: string[];
/** Runs the action and closes the palette. Omit on a parent-only row. */
perform?: () => void;
/** Opens a nested page of sub-actions — "pick an action, then its argument." */
children?: CommandPaletteAction[];
/** Input placeholder while browsing this action's `children` page. */
childPlaceholder?: string;
}
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
actions: CommandPaletteAction[];
/**
* Namespaces the recent/frequent ranking in storage. Give two palettes on
* one page (a global launcher and a scoped file switcher) different keys.
*/
recentKey?: string;
/** How many recent actions to surface at the top of the root page. */
recentLimit?: number;
/**
* The palette opens itself on ⌘K / Ctrl+K by default — that binding is
* the entire point of a command palette and near-universal, so it ships
* on rather than requiring separate wiring through the shortcut layer.
* Pass `false` to manage opening entirely yourself (e.g. via
* `useRegisterShortcut` if a consumer wants it in the `?` overlay too).
*/
enableHotkey?: boolean;
labels?: Partial<CommandPaletteLabels>;
className?: string;
}
interface PalettePage {
title: string;
items: CommandPaletteAction[];
placeholder?: string;
}
function getSearchText(action: CommandPaletteAction): string {
return [action.label, action.subtitle, ...(action.keywords ?? [])].filter(Boolean).join(" ");
}
/**
* The application-level ⌘K palette, built on the `command` primitive
* (cmdk) rather than replacing it — this owns paging, ranking, recents and
* focus restoration; cmdk still owns list rendering and roving selection.
*
* Fuzzy filtering and grouping are done here, not by cmdk's own filter
* (`shouldFilter={false}`), because ranking needs to blend fuzzy score with
* recent/frequent usage, which cmdk's built-in `command-score` matcher has
* no way to know about.
*
* Nested pages: an action with `children` pushes a new page instead of
* running; Backspace on an empty query pops back one level, mirroring the
* "stack of pages" pattern most cmd-k implementations converge on
* independently. The whole palette resets to its root page every time it
* opens, so a half-finished drill-down never haunts the next launch.
*/
export function CommandPalette({
open,
onOpenChange,
actions,
recentKey = "command-palette",
recentLimit = 5,
enableHotkey = true,
labels: labelsProp,
className,
}: CommandPaletteProps) {
const labels = resolveLabels(labelsProp);
const actionsRef = useRef(actions);
useEffect(() => {
actionsRef.current = actions;
});
const [stack, setStack] = useState<PalettePage[]>(() => [{ title: labels.title, items: actions }]);
const [query, setQuery] = useState("");
const { ranked: recentIds, recordUse } = useRecentItems(recentKey, { limit: recentLimit });
const currentPage = stack[stack.length - 1];
const atRoot = stack.length === 1;
// Every open starts fresh at the root page with whatever `actions` are
// current right now — a live-updating list never yanks the floor out from
// under a user mid-drill-down, because this only runs on the closed→open
// transition, not whenever `actions`'s identity changes.
useEffect(() => {
if (open) {
setStack([{ title: labels.title, items: actionsRef.current }]);
setQuery("");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
// Focus restoration: capture whatever had focus at the moment the palette
// opened — a toolbar button, a table row, nothing (a bare hotkey press) —
// and return focus there once it closes. This runs independently of
// whatever focus-return Base UI's Dialog attempts on its own, as an
// explicit guarantee: the brief's "must restore focus to wherever it was
// opened from" holds even when the opener isn't a Trigger element, which
// is exactly the case for a global ⌘K press.
const openerRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
openerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
return;
}
const opener = openerRef.current;
openerRef.current = null;
if (!opener) return;
const id = window.requestAnimationFrame(() => opener.focus());
return () => window.cancelAnimationFrame(id);
}, [open]);
useEffect(() => {
if (!enableHotkey) return;
function onKeyDown(event: KeyboardEvent) {
const mod = event.metaKey || event.ctrlKey;
if (mod && event.key.toLowerCase() === "k") {
event.preventDefault();
onOpenChange(!open);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enableHotkey, open, onOpenChange]);
const results = useFuzzySearch(currentPage.items, query, getSearchText);
const isSearching = query.trim().length > 0;
const groups = useMemo(() => {
const map = new Map<string, CommandPaletteAction[]>();
if (!isSearching && atRoot && recentIds.length > 0) {
const byId = new Map(currentPage.items.map((a) => [a.id, a]));
const recent = recentIds.map((id) => byId.get(id)).filter((a): a is CommandPaletteAction => !!a);
if (recent.length > 0) map.set(labels.recentGroup, recent);
}
for (const { item } of results) {
const list = map.get(item.group) ?? [];
list.push(item);
map.set(item.group, list);
}
return Array.from(map.entries());
}, [results, isSearching, atRoot, recentIds, currentPage.items, labels.recentGroup]);
const goBack = useCallback(() => {
setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
setQuery("");
}, []);
const handleSelect = useCallback(
(action: CommandPaletteAction) => {
if (action.children && action.children.length > 0) {
setStack((s) => [
...s,
{ title: action.label, items: action.children!, placeholder: action.childPlaceholder },
]);
setQuery("");
return;
}
recordUse(action.id);
action.perform?.();
onOpenChange(false);
},
[onOpenChange, recordUse],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Backspace" && query === "" && !atRoot) {
event.preventDefault();
goBack();
}
},
[query, atRoot, goBack],
);
const emptyTitle = labels.emptyTitle.replace("{query}", query);
return (
<CommandDialog
open={open}
onOpenChange={onOpenChange}
title={labels.title}
description={labels.description}
showCloseButton={false}
className={className}
>
<Command shouldFilter={false} loop onKeyDown={handleKeyDown}>
{!atRoot && (
<div className="flex items-center gap-1.5 border-b px-3 py-1.5">
<button
type="button"
onClick={goBack}
className="flex items-center gap-1 rounded-md px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-ring"
>
<ArrowLeft className="size-3" aria-hidden />
{labels.back}
</button>
<span className="text-xs text-muted-foreground/60">/</span>
<span className="truncate text-xs font-medium">{currentPage.title}</span>
</div>
)}
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={currentPage.placeholder ?? labels.inputPlaceholder}
/>
<CommandList>
<CommandEmpty>
<div className="flex flex-col items-center gap-2 px-6 py-8 text-center">
<SearchX aria-hidden className="size-6 text-muted-foreground/50" />
<p className="text-sm font-medium">{emptyTitle}</p>
<p className="max-w-64 text-xs text-muted-foreground">{labels.emptyBody}</p>
</div>
</CommandEmpty>
{groups.map(([group, items]) => (
<CommandGroup key={group} heading={group}>
{items.map((action) => {
const Icon = action.icon;
const hasChildren = !!action.children?.length;
return (
<CommandItem
key={`${group}:${action.id}`}
// Recent items intentionally re-appear under their own
// group too, so the `value` cmdk uses for roving
// highlight must be group-qualified — otherwise the two
// renderings of one action (Recent + its real group)
// would share a highlight state and both light up at
// once.
value={`${group}:${action.id}`}
onSelect={() => handleSelect(action)}
>
{Icon && <Icon aria-hidden />}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{action.label}</span>
{action.subtitle && (
<span className="truncate text-xs text-muted-foreground">
{action.subtitle}
</span>
)}
</div>
{hasChildren && (
<ChevronRight aria-hidden className="ml-auto size-3.5 text-muted-foreground" />
)}
{!hasChildren && action.shortcut && (
<CommandShortcut>{action.shortcut.join(" ")}</CommandShortcut>
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
<div
className={cn(
"flex items-center gap-3 border-t px-3 py-1.5 text-[11px] text-muted-foreground",
)}
>
<PaletteHint keys={["↑", "↓"]} label={labels.navigateHint} />
<PaletteHint keys={["↵"]} label={labels.selectHint} />
{!atRoot && <PaletteHint keys={["⌫"]} label={labels.backHint} />}
<PaletteHint keys={["esc"]} label={labels.closeHint} />
</div>
</Command>
</CommandDialog>
);
}
function PaletteHint({ keys, label }: { keys: string[]; label: string }) {
return (
<span className="flex items-center gap-1">
{keys.map((key, i) => (
<kbd
key={i}
className="inline-flex h-4.5 min-w-4.5 items-center justify-center rounded border border-border bg-muted px-1 font-mono text-[10px] uppercase"
>
{key}
</kbd>
))}
{label}
</span>
);
}
Docs
Opens on a bare hotkey rather than from a trigger element, which is exactly why it restores focus explicitly to whatever was focused before — a dialog primitive returns focus to its trigger, and here there is no trigger to return to.
Recents appear only on an empty query. Once someone types, ranking is what they typed; a recents list that keeps outranking a direct match is a palette people stop trusting.
Nested pages are a stack, and Backspace pops one level — the same model Linear and Raycast use, and the reason a palette can offer an action and then its argument without becoming a form.
Dependencies
lucide-reactcommanddialog@hextor/app-hooks