Command Palette
Keyboard-first command menu with a platform shortcut, nested pages, remembered recents and shortcut hints on each row.
Example
Installation
npx shadcn@latest add https://hirael.com/r/command-palette.jsonUsage
import {
CommandPalette,
CommandPaletteTrigger,
CommandPaletteDialog,
CommandPaletteInput,
CommandPaletteBack,
CommandPalettePage,
CommandPaletteGroup,
CommandPaletteItem,
CommandPaletteRecents,
CommandPaletteEmpty,
CommandPaletteList,
useCommandPalette,
} from "@/components/command-palette"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<CommandPalette />
| Prop | Type | Default |
|---|---|---|
open | boolean | none |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | none |
shortcutKey that opens the palette with the platform modifier. `null` binds nothing. | string | null | 'k' |
recentsKeylocalStorage key for recently chosen items. Omit to keep no history. | string | none |
maxRecents | number | 5 |
children | React.ReactNode | none |
<CommandPaletteTrigger />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CommandPaletteDialog />
+ native element props| Prop | Type | Default |
|---|---|---|
title | string | none |
className | string | none |
description | string | none |
showCloseButton | boolean | none |
<CommandPaletteInput />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CommandPaletteBack />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CommandPalettePage />
| Prop | Type | Default |
|---|---|---|
name* | string | none |
children | React.ReactNode | none |
<CommandPaletteGroup />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CommandPaletteItem />
+ native element props| Prop | Type | Default |
|---|---|---|
recentIdStable id, used for recents. Omit for an item you never want remembered. | string | none |
id | string | none |
label* | string | none |
valueWhat the list matches and keys on. Defaults to the label, scoped by the page or recents group the item sits in. | string | none |
pageOpens a `<CommandPalettePage>` with this name instead of running an action. | string | none |
shortcutHint rendered at the end of the row, e.g. `⌘N`. | string | none |
onSelect | (() => void) | none |
closeOnSelectCloses the palette after running. Off automatically when `page` is set. | boolean | true |
<CommandPaletteRecents />
| Prop | Type | Default |
|---|---|---|
heading | React.ReactNode | 'Recent' |
children* | (recent: CommandPaletteRecent) => React.ReactNode | none |
<CommandPaletteEmpty />
+ native element propsNo props of its own; forwards everything to the underlying element.
<CommandPaletteList />
+ native element propsNo props of its own; forwards everything to the underlying element.
Component source
'use client';
import * as React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/registry/hirael/bases/radix/ui/command';
import { Kbd } from '@/registry/hirael/bases/radix/ui/kbd';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';
export interface CommandPaletteRecent {
id: string;
label: string;
}
const NEVER_CHANGES = () => () => {};
const useIsApple = () =>
React.useSyncExternalStore(
NEVER_CHANGES,
() => /mac|iphone|ipad|ipod/i.test(navigator.userAgent),
() => false,
);
const NO_RECENTS: CommandPaletteRecent[] = [];
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') !== null;
};
const parseRecents = (raw: string | null): CommandPaletteRecent[] => {
try {
const parsed: unknown = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return NO_RECENTS;
return parsed.filter(
(entry): entry is CommandPaletteRecent =>
typeof entry === 'object' && entry !== null && typeof (entry as CommandPaletteRecent).id === 'string',
);
} catch {
return NO_RECENTS;
}
};
// Cached per raw string: useSyncExternalStore needs a stable snapshot for an unchanged value.
const recentsListeners = new Set<() => void>();
const recentsCache = new Map<string, { raw: string | null; value: CommandPaletteRecent[] }>();
// Answers for keys localStorage refused to write (private mode), so recents still last the visit.
const recentsMemory = new Map<string, CommandPaletteRecent[]>();
const subscribeRecents = (onStoreChange: () => void) => {
recentsListeners.add(onStoreChange);
window.addEventListener('storage', onStoreChange);
return () => {
recentsListeners.delete(onStoreChange);
window.removeEventListener('storage', onStoreChange);
};
};
const emitRecents = () => {
for (const listener of recentsListeners) listener();
};
const recentsSnapshot = (key: string): CommandPaletteRecent[] => {
const remembered = recentsMemory.get(key);
if (remembered) return remembered;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(key);
} catch {}
const cached = recentsCache.get(key);
if (cached && cached.raw === raw) return cached.value;
const value = parseRecents(raw);
recentsCache.set(key, { raw, value });
return value;
};
interface CommandPaletteContextValue {
open: boolean;
setOpen: (open: boolean) => void;
query: string;
setQuery: (query: string) => void;
pages: string[];
activePage: string | null;
pushPage: (page: string) => void;
popPage: () => void;
recents: CommandPaletteRecent[];
recordRecent: (entry: CommandPaletteRecent) => void;
clearRecents: () => void;
shortcutLabel: string;
}
const CommandPaletteContext = React.createContext<CommandPaletteContextValue | null>(null);
const usePalette = () => {
const ctx = React.useContext(CommandPaletteContext);
if (!ctx) {
throw new Error('CommandPalette compound parts must be used inside <CommandPalette>');
}
return ctx;
};
/** `null` is the top level. */
const PageContext = React.createContext<string | null>(null);
const RecentsContext = React.createContext(false);
export interface CommandPaletteProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Key that opens the palette with the platform modifier. `null` binds nothing. */
shortcut?: string | null;
/** localStorage key for recently chosen items. Omit to keep no history. */
recentsKey?: string;
maxRecents?: number;
children?: React.ReactNode;
}
const CommandPalette = ({
open: openProp,
defaultOpen = false,
onOpenChange,
shortcut = 'k',
recentsKey,
maxRecents = 5,
children,
}: CommandPaletteProps) => {
const [open, setOpen] = useControllableState({
prop: openProp,
defaultProp: defaultOpen,
onChange: onOpenChange,
});
const [query, setQuery] = React.useState('');
const [pages, setPages] = React.useState<string[]>([]);
// Keyed off the value itself, so a controlled close resets too.
const [prevOpen, setPrevOpen] = React.useState(open);
if (open !== prevOpen) {
setPrevOpen(open);
if (!open) {
setQuery('');
setPages([]);
}
}
const isApple = useIsApple();
const recents = React.useSyncExternalStore(
subscribeRecents,
() => (recentsKey ? recentsSnapshot(recentsKey) : NO_RECENTS),
() => NO_RECENTS,
);
React.useEffect(() => {
if (!shortcut) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.key.toLowerCase() !== shortcut.toLowerCase()) return;
if ((!event.metaKey && !event.ctrlKey) || event.shiftKey || event.altKey) return;
if (!open && isEditableTarget(event.target)) return;
event.preventDefault();
setOpen(!open);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [shortcut, open, setOpen]);
const pushPage = React.useCallback((page: string) => {
setPages((prev) => [...prev, page]);
setQuery('');
}, []);
const popPage = React.useCallback(() => {
setPages((prev) => prev.slice(0, -1));
}, []);
const recordRecent = React.useCallback(
(entry: CommandPaletteRecent) => {
if (!recentsKey) return;
const current = recentsSnapshot(recentsKey);
const next = [entry, ...current.filter((r) => r.id !== entry.id)].slice(0, maxRecents);
try {
window.localStorage.setItem(recentsKey, JSON.stringify(next));
} catch {
recentsMemory.set(recentsKey, next);
}
emitRecents();
},
[recentsKey, maxRecents],
);
const clearRecents = React.useCallback(() => {
if (!recentsKey) return;
recentsMemory.delete(recentsKey);
try {
window.localStorage.removeItem(recentsKey);
} catch {}
emitRecents();
}, [recentsKey]);
const ctx = React.useMemo<CommandPaletteContextValue>(
() => ({
open,
setOpen,
query,
setQuery,
pages,
activePage: pages[pages.length - 1] ?? null,
pushPage,
popPage,
recents,
recordRecent,
clearRecents,
shortcutLabel: shortcut ? `${isApple ? '⌘' : 'Ctrl '}${shortcut.toUpperCase()}` : '',
}),
[open, setOpen, query, pages, pushPage, popPage, recents, recordRecent, clearRecents, shortcut, isApple],
);
return <CommandPaletteContext.Provider value={ctx}>{children}</CommandPaletteContext.Provider>;
};
const CommandPaletteTrigger = ({ className, children, onClick, ...props }: React.ComponentProps<'button'>) => {
const ctx = usePalette();
return (
<Button
type="button"
variant="outline"
data-slot="command-palette-trigger"
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
ctx.setOpen(true);
}}
className={cn('w-full max-w-xs justify-start font-normal text-muted-foreground', className)}
{...props}
>
{children ?? <span>Search</span>}
{ctx.shortcutLabel && (
<Kbd data-slot="command-palette-kbd" className="ms-auto">
{ctx.shortcutLabel}
</Kbd>
)}
</Button>
);
};
export type CommandPaletteDialogProps = Omit<React.ComponentProps<typeof CommandDialog>, 'open' | 'onOpenChange'>;
const CommandPaletteDialog = (props: CommandPaletteDialogProps) => {
const ctx = usePalette();
return <CommandDialog open={ctx.open} onOpenChange={ctx.setOpen} showCloseButton={false} {...props} />;
};
const CommandPaletteInput = ({
className,
placeholder = 'Type a command or search…',
onKeyDown,
...props
}: Omit<React.ComponentProps<typeof CommandInput>, 'value' | 'onValueChange'>) => {
const ctx = usePalette();
return (
<div
data-slot="command-palette-input"
className={cn(
'flex items-center gap-2 border-b ps-3',
'[&_[data-slot=command-input-wrapper]]:flex-1 [&_[data-slot=command-input-wrapper]]:border-b-0 [&_[data-slot=command-input-wrapper]]:ps-0',
)}
>
<CommandPaletteBack />
<CommandInput
value={ctx.query}
onValueChange={ctx.setQuery}
placeholder={placeholder}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.key === 'Backspace' && ctx.query === '' && ctx.pages.length > 0) {
e.preventDefault();
ctx.popPage();
}
}}
className={className}
{...props}
/>
</div>
);
};
const CommandPaletteBack = ({ className, onClick, ...props }: React.ComponentProps<'button'>) => {
const ctx = usePalette();
if (ctx.pages.length === 0) return null;
return (
<button
type="button"
data-slot="command-palette-back"
aria-label="Back"
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded-[3px] bg-accent px-2 py-1 text-xs text-accent-foreground',
className,
)}
{...props}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) ctx.popPage();
}}
>
{ctx.pages[ctx.pages.length - 1]}
<ChevronRight className="size-3 rtl:rotate-180" />
</button>
);
};
export interface CommandPalettePageProps {
name: string;
children?: React.ReactNode;
}
const CommandPalettePage = ({ name, children }: CommandPalettePageProps) => {
const ctx = usePalette();
if (ctx.activePage !== name) return null;
return <PageContext.Provider value={name}>{children}</PageContext.Provider>;
};
const CommandPaletteGroup = ({ className, ...props }: React.ComponentProps<typeof CommandGroup>) => {
const ctx = usePalette();
const page = React.useContext(PageContext);
if (ctx.activePage !== page) return null;
return <CommandGroup data-slot="command-palette-group" className={className} {...props} />;
};
export interface CommandPaletteItemProps extends Omit<React.ComponentProps<typeof CommandItem>, 'onSelect'> {
/** Stable id, used for recents. Omit for an item you never want remembered. */
recentId?: string;
/** @deprecated Use `recentId`. Still read as the recents id when `recentId` is absent; always forwarded to the DOM. */
id?: string;
label: string;
/**
* What the list matches and keys on. Defaults to the label, scoped by the
* page or recents group the item sits in.
*/
value?: string;
/** Opens a `<CommandPalettePage>` with this name instead of running an action. */
page?: string;
/** Hint rendered at the end of the row, e.g. `⌘N`. */
shortcut?: string;
onSelect?: () => void;
/** Closes the palette after running. Off automatically when `page` is set. */
closeOnSelect?: boolean;
}
const CommandPaletteItem = ({
recentId: recentIdProp,
id,
label,
value,
page,
shortcut,
onSelect,
closeOnSelect = true,
className,
children,
...props
}: CommandPaletteItemProps) => {
const ctx = usePalette();
const scope = React.useContext(PageContext);
const inRecents = React.useContext(RecentsContext);
const itemValue = value ?? [inRecents ? 'recent' : scope, label].filter(Boolean).join(' ');
const recentId = recentIdProp ?? id;
return (
<CommandItem
id={id}
data-slot="command-palette-item"
value={itemValue}
onSelect={() => {
if (recentId) ctx.recordRecent({ id: recentId, label });
onSelect?.();
if (page) ctx.pushPage(page);
else if (closeOnSelect) ctx.setOpen(false);
}}
className={cn('gap-2', className)}
{...props}
>
{children ?? label}
{page && <ChevronRight className="ms-auto size-3.5 text-muted-foreground rtl:rotate-180" />}
{shortcut && !page && <Kbd className="ms-auto">{shortcut}</Kbd>}
</CommandItem>
);
};
export interface CommandPaletteRecentsProps {
heading?: React.ReactNode;
children: (recent: CommandPaletteRecent) => React.ReactNode;
}
/** Newest first. The child is a function because only the caller knows what a re-picked id does. */
const CommandPaletteRecents = ({ heading = 'Recent', children }: CommandPaletteRecentsProps) => {
const ctx = usePalette();
const page = React.useContext(PageContext);
if (ctx.activePage !== page || ctx.recents.length === 0) return null;
return (
<>
<CommandGroup heading={heading} data-slot="command-palette-recents">
<RecentsContext.Provider value>
{ctx.recents.map((recent) => (
<React.Fragment key={recent.id}>{children(recent)}</React.Fragment>
))}
</RecentsContext.Provider>
</CommandGroup>
<CommandSeparator />
</>
);
};
export {
CommandPalette,
CommandPaletteTrigger,
CommandPaletteDialog,
CommandPaletteInput,
CommandPaletteBack,
CommandPalettePage,
CommandPaletteGroup,
CommandPaletteItem,
CommandPaletteRecents,
CommandEmpty as CommandPaletteEmpty,
CommandList as CommandPaletteList,
usePalette as useCommandPalette,
};
Dependencies
shadcn registry
npm
Shipped