CommandPalette
Search registered actions and symbols in a dialog or inline command line, with shared recents and live shortcuts from the hotkey registry. Compose the dialog, groups and result markup with public parts built on your shadcn command component.
import { CommandGroup } from "@/components/ui/command"
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import {
CommandPalette,
CommandPaletteContent,
CommandPaletteDialog,
CommandPaletteEmpty,
CommandPaletteInput,
CommandPaletteItem,
CommandPaletteList,
CommandPaletteResults,
createActionRegistry,
} from "@/components/ui/command-palette"
export default function CommandPaletteDemo() {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState("None")
const [actions] = useState(() => {
const registry = createActionRegistry()
registry.register([
{ id: "orders", title: "Show orders", run: () => setSelected("Show orders") },
{ id: "positions", title: "Show positions", run: () => setSelected("Show positions") },
])
return registry
})
return (
<HotkeysProvider>
<div className="flex min-h-80 w-fit max-w-full flex-col justify-center gap-3 text-sm">
<button type="button" className="self-start rounded border border-border px-3 py-2 hover:bg-muted" onClick={() => setOpen(true)}>Open commands</button>
<p role="status">Selected: {selected}</p>
<CommandPalette actions={actions} open={open} onOpenChange={setOpen}>
<CommandPaletteDialog>
<CommandPaletteContent>
<CommandPaletteInput />
<CommandPaletteList>
<CommandPaletteEmpty>No results</CommandPaletteEmpty>
<CommandPaletteResults>
{(group) => (
<CommandGroup heading={group.heading}>
{(group.id === "recent" ? group.rows.slice(0, 5) : group.rows).map((row) => (
<CommandPaletteItem key={row.key} row={row}>{row.title}</CommandPaletteItem>
))}
</CommandGroup>
)}
</CommandPaletteResults>
</CommandPaletteList>
</CommandPaletteContent>
</CommandPaletteDialog>
</CommandPalette>
</div>
</HotkeysProvider>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/command-palette#v2.0.0
npx shadcn@latest add tradecn/ui/command-palette#v2.0.0
yarn dlx shadcn@latest add tradecn/ui/command-palette#v2.0.0
bunx --bun shadcn@latest add tradecn/ui/command-palette#v2.0.0
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add command kbdnpx shadcn@latest add command kbdyarn dlx shadcn@latest add command kbdbunx --bun shadcn@latest add command kbd -
Copy the files into your project:
components/ui/ command-palette.tsx import { cn } from "cn" import { createContext, Fragment, useCallback, useContext, useEffect, useInsertionEffect, useRef, useState, useSyncExternalStore, type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref } from "react" import { flushSync } from "react-dom" import { Command, CommandDialog, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useDeclaredHotkeyIds, useMaybeHotkeys } from "@/hooks/use-hotkeys" import { formatKeys, matchesKeys, scopeChain, type HotkeyEntry, type HotkeyRegistry, type Platform } from "@/lib/hotkeys" // Search and selection stay shared; callers own the dialog, groups and result markup. export interface PaletteAction { id: string title: string /** Optional secondary search text for the caller to display. */ subtitle?: string /** A hotkey scope, `panel:book`. The action is offered only when the palette is opened from inside that scope. Omit for everywhere. */ scope?: string /** The panel instance that owns a scoped action: a getter for an element that CONTAINS wherever focus rests in that panel, the HotkeyScope element itself — `useHotkeyScope()` hands it over. A root inside the scope misses clicks that focus the scope div. With several panels sharing one scope, the containing instance runs; registrations without it fall back to the latest. */ within?: (() => Element | null) | null keywords?: readonly string[] /** The heading the row sits under. */ group?: string /** A hotkey binding whose current keys are exposed on the row. */ bindingId?: string run: () => void /** Runs on Shift+Enter. Compose a CommandPaletteSecondary to make it discoverable. */ secondary?: { title: string; run: () => void } } export interface SymbolResult { symbol: string name?: string exchange?: string /** Asset class or instrument type, exposed as row.badge. */ kind?: string } export interface SymbolSearchAdapter { /** Reject or resolve as you like once `signal` aborts; a stale answer is dropped either way. */ search(query: string, signal: AbortSignal): Promise<readonly SymbolResult[]> /** Default 1. */ minLength?: number /** Default 150. */ debounceMs?: number } export type PaletteRecent = { kind: "action"; id: string; scope?: string } | { kind: "symbol"; symbol: SymbolResult } export interface ActionRegistry { /** Returns the unregister, which removes one copy of each action this call added. Same-id registrations stack; rows show the instance that answers. */ register(actions: PaletteAction | readonly PaletteAction[]): () => void /** Stable array reference until something changes. */ list(): readonly PaletteAction[] /** Most recent first. Stable array reference until something changes. */ recents(): readonly PaletteRecent[] /** Move to the front of the recents. The palette calls this after a row runs. */ touch(recent: PaletteRecent): void /** From the consumer's persistence. Does not notify `onRecentsChange`. */ loadRecents(recents: readonly PaletteRecent[]): void onRecentsChange(cb: (recents: readonly PaletteRecent[]) => void): () => void subscribe(cb: () => void): () => void } const recentKey = (r: PaletteRecent) => (r.kind === "action" ? `action:${JSON.stringify([r.scope || null, r.id])}` : `symbol:${JSON.stringify([r.symbol.symbol, r.symbol.exchange || null])}`) // A count or a delay a caller passes: a finite number at or above zero, or the default. const countOr = (value: number | undefined, fallback: number) => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback) // A recent read back from storage the app may not validate: an action with an id, or a symbol with its symbol, its // words text where it has them. Anything else is skipped, so stored data can't take the palette down while it renders. const isText = (value: unknown) => value === undefined || value === null || typeof value === "string" function isRecent(value: unknown): value is PaletteRecent { if (typeof value !== "object" || value === null) return false const entry = value as { kind?: unknown; id?: unknown; scope?: unknown; symbol?: unknown } if (entry.kind === "action") return typeof entry.id === "string" && isText(entry.scope) if (entry.kind !== "symbol" || typeof entry.symbol !== "object" || entry.symbol === null) return false const symbol = entry.symbol as Record<string, unknown> return typeof symbol.symbol === "string" && isText(symbol.name) && isText(symbol.exchange) && isText(symbol.kind) } // A recent read from storage keeps only the fields it can use: a null field reads as absent, so none is handed on. function readRecent(entry: PaletteRecent): PaletteRecent { if (entry.kind === "action") return entry.scope == null ? { kind: "action", id: entry.id } : entry return { kind: "symbol", symbol: Object.fromEntries(Object.entries(entry.symbol).filter(([, value]) => value !== null)) as unknown as SymbolResult } } export function createActionRegistry(options: { maxRecents?: number } = {}): ActionRegistry { // Infinity keeps every recent; NaN or a negative count keeps the default. const maxRecents = typeof options.maxRecents === "number" && options.maxRecents >= 0 ? Math.floor(options.maxRecents) : 8 const actions = new Map<string, PaletteAction[]>() const listeners = new Set<() => void>() const recentListeners = new Set<(recents: readonly PaletteRecent[]) => void>() let snapshot: readonly PaletteAction[] | null = null let recents: readonly PaletteRecent[] = [] function emit() { snapshot = null for (const cb of listeners) cb() } return { register(input) { const added = Array.isArray(input) ? (input as readonly PaletteAction[]) : [input as PaletteAction] for (const action of added) actions.set(action.id, [...(actions.get(action.id) ?? []), action]) emit() let undone = false return () => { // Only what this call registered, once: another instance's registration under the // same id stays, and a second call is a no-op. if (undone) return undone = true let changed = false for (const action of added) { const instances = actions.get(action.id) if (!instances) continue // One copy per call: a shared action object registered twice keeps its other copy. const at = instances.lastIndexOf(action) if (at < 0) continue const rest = [...instances.slice(0, at), ...instances.slice(at + 1)] if (rest.length) actions.set(action.id, rest) else actions.delete(action.id) changed = true } if (changed) emit() } }, list: () => (snapshot ??= [...actions.values()].flat()), recents: () => recents, touch(recent) { const key = recentKey(recent) // Running a scoped action retires the scope-less entry old versions persisted // for the same id — but only while no live unscoped registration owns that id, // since a current global action's entry is not a leftover. const leftover = recent.kind === "action" && !!recent.scope && !(actions.get(recent.id) ?? []).some((a) => !a.scope) const stale = (r: PaletteRecent) => recentKey(r) === key || (leftover && r.kind === "action" && r.id === recent.id && !r.scope) recents = [recent, ...recents.filter((r) => !stale(r))].slice(0, maxRecents) for (const cb of listeners) cb() for (const cb of recentListeners) cb(recents) }, loadRecents(next) { recents = (Array.isArray(next) ? next : []).filter(isRecent).slice(0, maxRecents).map(readRecent) for (const cb of listeners) cb() }, onRecentsChange(cb) { recentListeners.add(cb) return () => recentListeners.delete(cb) }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, } } /** * How well an action answers a query, or -1 when it does not. Every word of the query must land * somewhere; a word that starts the title beats one inside it, which beats a keyword, which beats * letters in order. */ export function scorePaletteAction(action: PaletteAction, query: string): number { const words = query.toLowerCase().split(/\s+/).filter(Boolean) if (!words.length) return 0 const title = action.title.toLowerCase() const rest = [action.subtitle, action.group, action.id, ...(action.keywords ?? [])].filter(Boolean).join(" ").toLowerCase() let total = 0 for (const word of words) { let score = -1 if (title === word) score = 100 else if (title.startsWith(word)) score = 80 else if (title.includes(` ${word}`)) score = 60 else if (title.includes(word)) score = 40 else if (rest.startsWith(word) || rest.includes(` ${word}`)) score = 30 else if (rest.includes(word)) score = 20 else if (isSubsequence(word, title)) score = 10 if (score < 0) return -1 total += score } return total } function isSubsequence(needle: string, haystack: string): boolean { let at = 0 for (const ch of needle) { at = haystack.indexOf(ch, at) if (at < 0) return false at++ } return true } export interface CommandPaletteLabels { /** The dialog's accessible name, and the go-bar's. */ title: string description: string placeholder: string recent: string actions: string commands: string symbols: string /** The description of the binding the palette declares for itself. */ hotkey: string } const PALETTE_LABELS: CommandPaletteLabels = { title: "Command palette", description: "Search for a command or a symbol", placeholder: "Type a command or a symbol…", recent: "Recent", actions: "Actions", commands: "Commands", symbols: "Symbols", hotkey: "Open the command palette", } const GO_BAR_LABELS: CommandPaletteLabels = { ...PALETTE_LABELS, title: "Command line", placeholder: "Symbol, function, or command", hotkey: "Focus the command line" } export interface CommandPaletteProps { actions: ActionRegistry /** Dialog keyboard behavior or inline input/dropdown behavior. Compose the corresponding parts. */ variant?: "palette" | "go-bar" open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void symbols?: SymbolSearchAdapter onSymbolSelect?: (symbol: SymbolResult) => void /** Shift+Enter on a symbol row, after `onSymbolSelect`'s usual work is yours to repeat or skip. */ symbolSecondary?: { title: string; run: (symbol: SymbolResult) => void } /** Rows for what has been typed, `AAPL GP`. The first row is what Enter runs. */ goBarGrammar?: (input: string) => readonly PaletteAction[] /** Defaults to the registry from `HotkeysProvider`, when there is one. `null` opts out. */ hotkeys?: HotkeyRegistry | null /** Keys for the binding the palette declares for itself: `palette.open` (default `mod+k`) or `go-bar.focus` (default `/`). `false` declares nothing. */ hotkey?: string | false labels?: Partial<CommandPaletteLabels> /** Compose a dialog or an inline content area. */ children: ReactNode } export interface PaletteRow { key: string title: string subtitle?: string badge?: string keys?: string run: () => void secondary?: { title: string; run: () => void } recent: PaletteRecent | null } export interface PaletteGroup { id: string heading: string rows: readonly PaletteRow[] } type MutablePaletteGroup = PaletteGroup & { rows: PaletteRow[] } const NO_SYMBOLS: readonly SymbolResult[] = [] const NO_ENTRIES: readonly HotkeyEntry[] = [] const AMBIENT_SCOPES = "editing global" const SLOT = "tradecn-command-palette" // Where focus last was outside any palette, as a scope chain. It decides which scoped actions are // on offer, and it has to be read before the palette takes focus for itself. let focusScopes = AMBIENT_SCOPES // The focused element itself, for telling apart panel instances that share a scope name. // Read when a row runs, never subscribed to. let focusElement: Element | null = null const focusListeners = new Set<() => void>() function onFocusIn(event: globalThis.FocusEvent) { const target = event.target as Element | null if (target && typeof target.closest === "function" && target.closest(`[data-slot="${SLOT}"]`)) return focusElement = target const next = scopeChain(target).join(" ") if (next === focusScopes) return focusScopes = next for (const cb of focusListeners) cb() } function subscribeFocusScopes(cb: () => void) { if (!focusListeners.size) { // Nobody was listening, so whatever was remembered is stale. Start from where focus is now. const focused = document.activeElement const outside = focused?.closest(`[data-slot="${SLOT}"]`) ? null : focused focusElement = outside focusScopes = outside ? scopeChain(outside).join(" ") : AMBIENT_SCOPES document.addEventListener("focusin", onFocusIn) } focusListeners.add(cb) return () => { focusListeners.delete(cb) if (!focusListeners.size) document.removeEventListener("focusin", onFocusIn) } } const getFocusElement = () => focusElement // The keys that move through the rows, handed to the list when they come from a popout window. const ROW_KEYS: ReadonlySet<string> = new Set(["ArrowUp", "ArrowDown", "Home", "End"]) const getFocusScopes = () => focusScopes const getServerFocusScopes = () => AMBIENT_SCOPES const subscribeNothing = () => () => {} const getNoEntries = () => NO_ENTRIES function useSymbolSearch(adapter: SymbolSearchAdapter | undefined, query: string, enabled: boolean) { const [found, setFound] = useState<{ adapter?: SymbolSearchAdapter; query: string; results: readonly SymbolResult[]; signal?: AbortSignal }>({ query: "", results: NO_SYMBOLS }) const active = enabled && adapter !== undefined && query.length >= countOr(adapter.minLength, 1) useEffect(() => { if (!adapter || !active) return const controller = new AbortController() const settle = (results: readonly SymbolResult[]) => { if (!controller.signal.aborted) setFound({ adapter, query, results, signal: controller.signal }) } const timer = setTimeout(() => { Promise.resolve().then(() => { if (!controller.signal.aborted) return adapter.search(query, controller.signal) return NO_SYMBOLS }).then(settle, () => settle(NO_SYMBOLS)) }, Math.min(countOr(adapter.debounceMs, 150), 2_147_483_647)) return () => { clearTimeout(timer) controller.abort() } }, [adapter, active, query]) // Only answers to the query on screen. Enter on a row left over from three letters ago is how the wrong symbol gets loaded. const current = active && found.adapter === adapter && found.query === query && !found.signal?.aborted return { results: current ? found.results : NO_SYMBOLS, loading: active && !current } } interface PaletteRootState { options: CommandPaletteProps labels: CommandPaletteLabels hotkeys: HotkeyRegistry | null ownBindingId: string | null scopes: string capturedEl: Element | null open: boolean setOpen: (open: boolean) => void inputRef: { current: HTMLInputElement | null } } const RootContext = createContext<PaletteRootState | null>(null) function usePaletteRoot() { const root = useContext(RootContext) if (!root) throw new Error("CommandPalette parts require CommandPalette") return root } export interface CommandPaletteState { groups: readonly PaletteGroup[] input: string setInput: (input: string) => void loading: boolean open: boolean setOpen: (open: boolean) => void platform?: Platform /** Select a currently offered row. Secondary falls back to primary when absent. */ select: (row: PaletteRow, secondary?: boolean) => void } const ContentContext = createContext<CommandPaletteState | null>(null) const ItemContext = createContext<{ row: PaletteRow; disabled: boolean } | null>(null) /** Read the nearest content's search state without creating subscriptions or requests. */ export function useCommandPalette(): CommandPaletteState { const state = useContext(ContentContext) if (!state) throw new Error("useCommandPalette requires CommandPaletteContent") return state } function assignPaletteRef<T>(ref: Ref<T> | undefined, node: T | null) { if (typeof ref === "function") return ref(node) if (ref) ref.current = node } // Attach both refs to the actual node, including replacements made by the consumer's primitive. function usePaletteRef<T>(localRef: { current: T | null }, forwardedRef: Ref<T> | undefined) { return useCallback((node: T | null) => { localRef.current = node const cleanup = assignPaletteRef(forwardedRef, node) return () => { localRef.current = null if (typeof cleanup === "function") cleanup() else assignPaletteRef(forwardedRef, null) } }, [localRef, forwardedRef]) } export type CommandPaletteContentProps = Omit<ComponentProps<typeof Command>, "children" | "shouldFilter"> & { children: ReactNode } export function CommandPaletteContent({ children, ref, className, onKeyDown: onKeyDownProp, onBlur, ...props }: CommandPaletteContentProps) { const { options, labels, hotkeys, ownBindingId, scopes, capturedEl, open, setOpen, inputRef } = usePaletteRoot() const { actions, symbols, onSymbolSelect, symbolSecondary, goBarGrammar, variant = "palette" } = options const root = useRef<HTMLDivElement>(null) const contentRef = usePaletteRef(root, ref) const onDone = () => { setOpen(false) if (variant === "go-bar") inputRef.current?.blur() } const [input, setInput] = useState("") const query = input.trim() const list = useSyncExternalStore(actions.subscribe, actions.list, actions.list) const recents = useSyncExternalStore(actions.subscribe, actions.recents, actions.recents) const entries = useSyncExternalStore(hotkeys?.subscribe ?? subscribeNothing, hotkeys?.list ?? getNoEntries, hotkeys?.list ?? getNoEntries) const search = useSymbolSearch(symbols, query, open) // Radix focuses the dialog in the commit that mounts it; Base UI does it a few milliseconds later. // Keys pressed in that gap land on the body, where a single-key hotkey would take them. Someone who // pressed mod+k is already working the palette, so until focus arrives its keys are the palette's: // text goes into the query, the palette's own shortcut and Escape close it, Enter runs the highlighted row, // and nothing reaches the dispatcher. const early = useRef<{ enter: (shift: boolean) => void; escape: () => void; own: (event: KeyboardEvent) => boolean }>({ enter: () => {}, escape: () => {}, own: () => false }) useEffect(() => { if (variant !== "palette" || !open) return const inside = (node: EventTarget | null) => Boolean(node instanceof Node && root.current?.contains(node)) if (inside(document.activeElement)) return const onFocus = (event: globalThis.FocusEvent) => { if (inside(event.target)) stop() } const onKey = (event: KeyboardEvent) => { if (inside(event.target)) return stop() // 229 is a key an input method takes, which Safari sends before isComposing says so; a keydown with no key, // which Chrome's autofill sends, is no key at all. if (event.isComposing || event.keyCode === 229 || typeof event.key !== "string") return event.stopPropagation() // The palette's own shortcut comes first, so one on Shift+Enter closes the palette rather than running a row. if (early.current.own(event)) early.current.escape() else if (event.key === "Enter") early.current.enter(event.shiftKey) else if (event.key === "Escape") early.current.escape() else if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return else setInput((typed) => typed + event.key) event.preventDefault() } const timer = setTimeout(() => stop(), 1000) function stop() { clearTimeout(timer) document.removeEventListener("keydown", onKey, true) document.removeEventListener("focusin", onFocus, true) } document.addEventListener("keydown", onKey, true) document.addEventListener("focusin", onFocus, true) return stop }, [variant, open]) // A key from a popout window opens the palette in the window that renders it, and the keyboard stays in the popout, // where this document's focus never reaches. While the palette is open, every key in that window is the palette's, // with or without a modifier, so none reaches the window's own bindings: text goes into the query, Option and AltGr // characters and a paste included, Backspace deletes, Up, Down, Home, and End move through the rows, the palette's // own shortcut closes it, and otherwise Enter runs the highlighted row and Escape closes under any modifiers. Another // shortcut with Ctrl or Meta does nothing to a field there but copy, and elsewhere the browser keeps its own. A press // in the popout closes the palette, since the person has gone back to that window. A key, a press, or a paste on the // palette itself, when its caller renders it in that window, is the palette's own. useEffect(() => { const away = variant === "palette" && open ? capturedEl?.ownerDocument : undefined if (!away || away === document) return // By node type, not instanceof: the popout's nodes can belong to its own window's realm. const editable = (node: EventTarget | null) => (node as Element | null)?.nodeType === 1 && (node as Element).closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])") !== null // The keys this capture hands the palette's input would come back to it if the palette were in that window too. const mine = (node: EventTarget | null) => Boolean((node as Node | null)?.nodeType && root.current?.contains(node as Node)) const onKey = (event: KeyboardEvent) => { if (mine(event.target) || event.isComposing || event.keyCode === 229 || typeof event.key !== "string") return event.stopPropagation() // The palette's own shortcut comes first, so one on a modified Enter closes the palette rather than running a row. if (early.current.own(event) || event.key === "Escape") { event.preventDefault() early.current.escape() return } if (event.key === "Enter") { event.preventDefault() early.current.enter(event.shiftKey) return } if ((event.ctrlKey || event.metaKey) && !event.getModifierState("AltGraph")) { // Copy keeps its default, and a paste lands in the query through the paste event. if (editable(event.target) && !/^[cv]$/i.test(event.key)) event.preventDefault() return } event.preventDefault() const field = inputRef.current if (ROW_KEYS.has(event.key) && !event.altKey) field?.dispatchEvent(new (field.ownerDocument.defaultView ?? window).KeyboardEvent("keydown", { key: event.key, bubbles: true, cancelable: true })) else if (event.key === "Backspace") setInput((typed) => typed.slice(0, -1)) else if (event.key.length === 1) setInput((typed) => typed + event.key) } const onPaste = (event: ClipboardEvent) => { if (mine(event.target)) return event.stopPropagation() event.preventDefault() const text = event.clipboardData?.getData("text/plain") ?? "" if (text) setInput((typed) => typed + text.replace(/\s+/g, " ")) } const onPress = (event: PointerEvent) => { if (!mine(event.target)) early.current.escape() } away.addEventListener("keydown", onKey, true) away.addEventListener("paste", onPaste, true) away.addEventListener("pointerdown", onPress, true) return () => { away.removeEventListener("keydown", onKey, true) away.removeEventListener("paste", onPaste, true) away.removeEventListener("pointerdown", onPress, true) } }, [variant, open, capturedEl, inputRef]) const active = new Set(scopes.split(" ")) const keysOf = new Map(entries.map((e) => [e.id, e.keys])) const scopeLabel = (scope: string) => scope.replace(/^panel:/, "") // With several panel instances sharing a scope, the one whose within() contains the // element focus froze on at opening answers — the fence rule, applied to actions. A // fenced registration never answers outside its element; registrations without within // fall back latest-first. The whole row is built from the resolved instance, so its // label, keys, and secondary never belong to another panel. // An empty scope string means unscoped wherever scopes are compared or keyed. const scopeOf = (a: { scope?: string }) => a.scope || undefined const instanceOf = (action: PaletteAction): PaletteAction | null => { const instances = list.filter((a) => a.id === action.id && scopeOf(a) === scopeOf(action)) if (capturedEl?.isConnected) for (let i = instances.length - 1; i >= 0; i--) { const root = instances[i]!.within?.() if (root?.contains(capturedEl)) return instances[i]! } for (let i = instances.length - 1; i >= 0; i--) if (!instances[i]!.within) return instances[i]! return null } const actionRow = (action: PaletteAction, prefix: string, recent: PaletteRecent | null): PaletteRow => ({ // A scoped key is a JSON tuple behind its own marker, so no unscoped id can spell it. key: action.scope ? `${prefix}!${JSON.stringify([action.scope, action.id])}` : `${prefix}:${action.id}`, title: action.title, subtitle: action.subtitle, badge: action.scope ? scopeLabel(action.scope) : undefined, keys: (action.bindingId && keysOf.get(action.bindingId)) || undefined, run: action.run, secondary: action.secondary, recent, }) const symbolRow = (symbol: SymbolResult, prefix: string): PaletteRow => ({ // The same marked JSON spelling as scoped action rows: colons in a symbol or an // empty exchange cannot collide two instruments onto one key. key: `${prefix}!${JSON.stringify([symbol.symbol, symbol.exchange || null])}`, title: symbol.symbol, subtitle: [symbol.name, symbol.exchange].filter(Boolean).join(" · ") || undefined, badge: symbol.kind, run: () => onSymbolSelect?.(symbol), secondary: symbolSecondary && { title: symbolSecondary.title, run: () => symbolSecondary.run(symbol) }, recent: { kind: "symbol", symbol }, }) // One row per action id and scope: the instance that answers, resolved once against // the frozen capture. An action fenced to panels that never contained it stays off. const seen = new Set<string>() const offered: PaletteAction[] = [] for (const a of list) { if (a.scope && !active.has(a.scope)) continue const key = JSON.stringify([scopeOf(a) ?? null, a.id]) if (seen.has(key)) continue seen.add(key) const resolved = instanceOf(a) if (resolved) offered.push(resolved) } const sections: MutablePaletteGroup[] = [] if (!query) { const rows: PaletteRow[] = [] const emittedRecents = new Set<string>() for (const recent of recents) { if (recent.kind === "symbol") { const row = symbolRow(recent.symbol, "recent-symbol") if (!emittedRecents.has(row.key)) { emittedRecents.add(row.key) rows.push(row) } } else { const action = offered.find((a) => a.id === recent.id && scopeOf(a) === scopeOf(recent)) ?? (scopeOf(recent) === undefined && !list.some((a) => a.id === recent.id && !a.scope) ? offered.find((a) => a.id === recent.id) : undefined) if (action) { // The row's identity is the resolved action's, so running a v1 scope-less // entry saves the scope it ran in and retires the old spelling. const row = actionRow(action, "recent", { kind: "action", id: action.id, scope: scopeOf(action) }) if (!emittedRecents.has(row.key)) { emittedRecents.add(row.key) rows.push(row) } } } } if (rows.length) sections.push({ id: "recent", heading: labels.recent, rows }) for (const action of offered) pushRow(sections, action.group ?? labels.actions, actionRow(action, "action", { kind: "action", id: action.id, scope: scopeOf(action) })) } else { const commands = goBarGrammar?.(query) ?? [] if (commands.length) sections.push({ id: "commands", heading: labels.commands, rows: commands.map((c) => actionRow(c, "command", null)) }) const scored = offered.map((action) => ({ action, score: scorePaletteAction(action, query) })).filter((s) => s.score >= 0) scored.sort((a, b) => b.score - a.score) for (const { action } of scored) pushRow(sections, action.group ?? labels.actions, actionRow(action, "action", { kind: "action", id: action.id, scope: scopeOf(action) })) if (search.results.length) sections.push({ id: "symbols", heading: labels.symbols, rows: search.results.map((s) => symbolRow(s, "symbol")) }) } const rowsByKey = new Map(sections.flatMap((s) => s.rows).map((r) => [r.key, r])) const select = (requested: PaletteRow, secondary = false) => { const row = rowsByKey.get(requested.key) if (!row || !open) return const run = secondary && row.secondary ? row.secondary.run : row.run setInput("") onDone() if (row.recent) actions.touch(row.recent) run() } const secondaryDisabled = (item: Element | null | undefined) => Boolean(item?.querySelector("[data-secondary]:disabled")) // Swapped before any layout effect runs, as the shortcut's handler is. useInsertionEffect(() => { early.current = { enter(shift) { const selected = root.current?.querySelector('[cmdk-item][aria-selected="true"]:not([aria-disabled="true"])') const row = rowsByKey.get(selected?.getAttribute("data-row") ?? "") if (row && !(shift && row.secondary && secondaryDisabled(selected))) select(row, shift) }, escape: onDone, own(event) { const own = ownBindingId ? keysOf.get(ownBindingId) : undefined return Boolean(own && matchesKeys(event, own, hotkeys?.platform)) }, } }) const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { onKeyDownProp?.(event) if (event.defaultPrevented || event.nativeEvent.isComposing) return // The dispatcher stops at the dialog, so the key that opened the palette closes it from here, ahead of the keys the // palette reads itself: a shortcut on Shift+Enter closes rather than running a row's second action. if (variant === "palette" && early.current.own(event.nativeEvent)) { event.preventDefault() onDone() return } if (event.key === "Enter" && event.shiftKey) { const selected = event.currentTarget.querySelector('[cmdk-item][aria-selected="true"]:not([aria-disabled="true"])') const row = rowsByKey.get(selected?.getAttribute("data-row") ?? "") if (!row?.secondary) return event.preventDefault() if (!secondaryDisabled(selected)) select(row, true) } else if (variant === "go-bar" && event.key === "Escape") { event.preventDefault() setInput("") onDone() } } return ( <ContentContext value={{ groups: sections, input, setInput, loading: search.loading, open, setOpen, platform: hotkeys?.platform, select }}> <Command ref={contentRef} loop label={labels.title} {...props} data-slot="tradecn-command-palette" data-variant={variant} shouldFilter={false} className={cn("lining-nums tabular-nums", variant === "go-bar" && "relative h-auto overflow-visible bg-transparent p-0", className)} onKeyDown={onKeyDown} onBlur={(event) => { onBlur?.(event) if (!event.defaultPrevented && variant === "go-bar" && !event.currentTarget.contains(event.relatedTarget)) setOpen(false) }} > <div className="contents" onKeyDown={(event) => { // cmdk handles Enter and navigation at its root. Application controls keep their own keys. if (event.target === inputRef.current) return const own = ownBindingId && keysOf.get(ownBindingId) if (own && matchesKeys(event.nativeEvent, own, hotkeys?.platform)) return if (["Enter", "ArrowDown", "ArrowUp", "Home", "End"].includes(event.key) || (event.ctrlKey && ["n", "j", "p", "k"].includes(event.key))) event.stopPropagation() }} > {children} </div> </Command> </ContentContext> ) } function pushRow(groups: MutablePaletteGroup[], heading: string, row: PaletteRow) { const id = `actions:${heading}` const group = groups.find((group) => group.id === id) if (group) group.rows.push(row) else groups.push({ id, heading, rows: [row] }) } export function CommandPalette(options: CommandPaletteProps) { const { actions, children, variant = "palette", open: openProp, defaultOpen = false, onOpenChange, hotkeys: hotkeysProp, hotkey, labels: labelsProp } = options if (!actions) throw new Error("CommandPalette requires actions") const fromContext = useMaybeHotkeys() const hotkeys = hotkeysProp === undefined ? fromContext : hotkeysProp const labels = { ...(variant === "palette" ? PALETTE_LABELS : GO_BAR_LABELS), ...labelsProp } const [uncontrolled, setUncontrolled] = useState(defaultOpen) const open = openProp ?? uncontrolled const inputRef = useRef<HTMLInputElement>(null) // Freeze where focus was at the moment of opening: a render later the palette itself has it. // The element itself freezes too, so a detour over the dialog's close button cannot change // which panel instance answers. A palette mounted already open reads the live document, // since the shared trackers only learn about focus once something subscribes. const liveScopes = useSyncExternalStore(subscribeFocusScopes, getFocusScopes, getServerFocusScopes) // The element a key opened the palette from. The shared tracker hears focus in this document only, and the key can // come from a popout window whose panel is the one that should answer. const [keyedFrom, setKeyedFrom] = useState<Element | null>(null) const [wasOpen, setWasOpen] = useState(open) const [frozen, setFrozen] = useState<{ scopes: string; el: Element | null }>(() => { if (!open || typeof document === "undefined") return { scopes: liveScopes, el: null } const focused = document.activeElement const outside = focused?.closest(`[data-slot="${SLOT}"]`) ? null : focused return { scopes: outside ? scopeChain(outside).join(" ") : AMBIENT_SCOPES, el: outside } }) if (open !== wasOpen) { setWasOpen(open) const from = keyedFrom?.isConnected && !keyedFrom.closest(`[data-slot="${SLOT}"]`) ? keyedFrom : null if (open) setFrozen(from ? { scopes: scopeChain(from).join(" "), el: from } : { scopes: liveScopes, el: getFocusElement() }) if (keyedFrom) setKeyedFrom(null) } else if (keyedFrom && !open) { // An open a controlled parent refused: the key's element must not stand in for a later click's. setKeyedFrom(null) } const scopes = frozen.scopes const capturedEl = frozen.el const setOpen = (next: boolean) => { if (openProp === undefined) setUncontrolled(next) onOpenChange?.(next) } const bindingId = variant === "palette" ? "palette.open" : "go-bar.focus" const keys = hotkey === false ? null : (hotkey ?? (variant === "palette" ? "mod+k" : "/")) const description = labels.hotkey const group = labels.title const onHotkey = useRef<(event: KeyboardEvent) => void>(() => {}) // Swapped before any layout effect runs, as useHotkey's handler is: a key between a commit and its passive effects // reaches what that commit rendered. useInsertionEffect(() => { onHotkey.current = (event) => { if (variant !== "palette") return void inputRef.current?.focus() // Only a key from another document, a popout's, names its element: here the focus tracker already knows // where focus was, and keeps the last panel's offer when focus fell to the body. const target = event.target as Element | null if (!open && target && target.nodeType === 1 && target.ownerDocument !== document) { // React hears a key from another window as no event of its own, so it would render the opening a task later, // and the popout's next key could reach that window's bindings first. The opening renders now, and the // capture listens there before this handler returns. flushSync(() => { setKeyedFrom(target) setOpen(true) }) return } setOpen(!open) } }) // Keyed to the registry this palette actually uses: an explicit `hotkeys` registry ignores a // provider's declarations for its own, different registry. const declaredByProvider = useDeclaredHotkeyIds(hotkeys) useEffect(() => { if (!hotkeys || keys === null) return // A consumer that declared this id already owns its keys and its wording, whether it declared // on the registry before render or through the provider's bindings, whose effect runs after // this one and must not be mistaken for ours. const scope = variant === "palette" ? ("editing" as const) : ("global" as const) const declared = declaredByProvider.has(bindingId) || hotkeys.list().some((entry) => entry.id === bindingId) if (!declared) hotkeys.register({ id: bindingId, keys, scope, description, group }) const unbind = hotkeys.bind(bindingId, (event) => onHotkey.current(event)) return () => { unbind() if (declared) return // Ownership is the exact declaration this effect made: this spelling, wording, scope, and // group, with no behavior fields. A replacement differing anywhere is the consumer's to // keep, even one a subscriber registered during the register call above. A field-identical // redeclaration stays indistinguishable without a registration handle; it is the one shape // this cannot tell apart. const current = hotkeys.list().find((entry) => entry.id === bindingId) if (current && current.declaredKeys === keys && current.description === description && current.scope === scope && current.group === group && current.when === undefined && current.repeat === undefined && current.preventDefault === undefined) hotkeys.unregister(bindingId) } }, [hotkeys, keys, bindingId, variant, description, group, declaredByProvider]) return <RootContext value={{ options, labels, hotkeys, ownBindingId: keys === null ? null : bindingId, scopes, capturedEl, open, setOpen, inputRef }}>{children}</RootContext> } export type CommandPaletteDialogProps = Omit<ComponentProps<typeof CommandDialog>, "open" | "defaultOpen" | "onOpenChange" | "children"> & { children: ReactNode } export function CommandPaletteDialog({ children, ...props }: CommandPaletteDialogProps) { const { open, setOpen, labels } = usePaletteRoot() return <CommandDialog title={labels.title} description={labels.description} {...props} open={open} onOpenChange={setOpen}>{children}</CommandDialog> } export type CommandPaletteInputProps = Omit<ComponentProps<typeof CommandInput>, "value" | "defaultValue" | "onValueChange" | "onChange"> export function CommandPaletteInput({ ref, onFocus, ...props }: CommandPaletteInputProps) { const { input, setInput } = useCommandPalette() const { labels, options, setOpen, inputRef } = usePaletteRoot() const fieldRef = usePaletteRef(inputRef, ref) return ( <CommandInput placeholder={labels.placeholder} {...props} ref={fieldRef} value={input} onValueChange={setInput} onFocus={(event) => { onFocus?.(event) if (!event.defaultPrevented && options.variant === "go-bar") setOpen(true) }} /> ) } export function CommandPaletteList({ className, onMouseDown, ...props }: ComponentProps<typeof CommandList>) { const { options, open } = usePaletteRoot() const inline = options.variant === "go-bar" if (inline && !open) return null return ( <CommandList {...props} className={cn(inline && "absolute top-full right-0 left-0 z-50 mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md", className)} onMouseDown={(event) => { onMouseDown?.(event) if (inline) event.preventDefault() }} /> ) } export function CommandPaletteEmpty(props: Omit<ComponentProps<typeof CommandEmpty>, "children"> & { children: ReactNode }) { return <CommandEmpty {...props} /> } /** Optional group iterator. Use useCommandPalette for a different collection order or structure. */ export function CommandPaletteResults({ children }: { children: (group: PaletteGroup) => ReactNode }) { const { groups } = useCommandPalette() return groups.map((group) => <Fragment key={group.id}>{children(group)}</Fragment>) } export type CommandPaletteItemProps = Omit<ComponentProps<typeof CommandItem>, "value" | "onSelect" | "onClick" | "onPointerMove" | "children"> & { row: PaletteRow; children: ReactNode } export function CommandPaletteItem({ row, children, disabled, ...props }: CommandPaletteItemProps) { const { select } = useCommandPalette() return ( <ItemContext value={{ row, disabled: Boolean(disabled) }}> <CommandItem {...props} disabled={disabled} value={row.key} data-row={row.key} onSelect={() => { if (!disabled) select(row) }}>{children}</CommandItem> </ItemContext> ) } export function CommandPaletteSecondary({ children, className, onClick, onMouseDown, ...props }: Omit<ComponentProps<"button">, "children"> & { children: ReactNode }) { const item = useContext(ItemContext) const { select } = useCommandPalette() if (!item) throw new Error("CommandPaletteSecondary requires CommandPaletteItem") const { row, disabled } = item if (!row.secondary) return null return ( <button type="button" tabIndex={-1} className={cn("hidden items-center gap-1 in-data-[selected=true]:inline-flex disabled:opacity-50", className)} {...props} onMouseDown={(event) => { onMouseDown?.(event) event.preventDefault() }} data-secondary="" disabled={disabled || props.disabled} onClick={(event) => { event.stopPropagation() onClick?.(event) if (!event.defaultPrevented && !disabled) select(row, true) }} > {children} </button> ) } export function CommandPaletteKeys({ keys, platform: platformProp, className, ...props }: Omit<ComponentProps<"span">, "children"> & { keys: string; platform?: Platform }) { const root = useContext(RootContext) return ( <span {...props} className={cn("inline-flex items-center gap-1", className)}> {formatKeys(keys, platformProp ?? root?.hotkeys?.platform).map((caps, i) => ( <KbdGroup key={i}>{caps.map((cap) => <Kbd key={cap} className="text-xs">{cap}</Kbd>)}</KbdGroup> ))} </span> ) }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ComponentProps, type ReactNode } from "react" import { createHotkeyRegistry, type HandlerScope, type HotkeyBinding, type HotkeyEntry, type HotkeyHandler, type HotkeyRegistry, type HotkeyScopeName, type HotkeyTarget } from "@/lib/hotkeys" // React bindings for the hotkey registry. The registry does the work and knows nothing about React; // this file puts one in context, attaches its listener, marks scopes in the DOM, and keeps a // component's handler attached for as long as the component is mounted. const RegistryContext = createContext<HotkeyRegistry | null>(null) const ScopeContext = createContext<HandlerScope | null>(null) const EMPTY_DECLARED: ReadonlySet<string> = new Set() const EMPTY_DECLARED_MAP: ReadonlyMap<HotkeyRegistry, ReadonlySet<string>> = new Map() const DeclaredContext = createContext<ReadonlyMap<HotkeyRegistry, ReadonlySet<string>>>(EMPTY_DECLARED_MAP) export interface HotkeysProviderProps { /** Bring your own to declare bindings and load overrides before the first render. One is created otherwise. */ registry?: HotkeyRegistry /** Declared on mount, removed on unmount. Keep the array's identity stable: a module constant. */ bindings?: readonly HotkeyBinding[] /** Where to listen. `document` by default; a popout window's document for a second one. */ target?: HotkeyTarget children?: ReactNode } export function HotkeysProvider({ registry, bindings, target, children }: HotkeysProviderProps) { const [own] = useState(() => createHotkeyRegistry()) const value = registry ?? own // Visible during render, before any effect runs: a child that supplies a default for an id // the consumer declares here must see the declaration on its first pass, not an effect later. // Keyed by registry, so a nested provider with its own registry inherits nothing it doesn't own. const parentDeclared = useContext(DeclaredContext) const declared = useMemo(() => { if (!bindings?.length) return parentDeclared const next = new Map(parentDeclared) next.set(value, new Set([...(parentDeclared.get(value) ?? EMPTY_DECLARED), ...bindings.map((binding) => binding.id)])) return next }, [parentDeclared, bindings, value]) useEffect(() => value.attach(target), [value, target]) useEffect(() => { if (!bindings) return for (const binding of bindings) value.register(binding) return () => { for (const binding of bindings) value.unregister(binding.id) } }, [value, bindings]) return <RegistryContext.Provider value={value}><DeclaredContext.Provider value={declared}>{children}</DeclaredContext.Provider></RegistryContext.Provider> } /** The registry from the nearest provider. */ export function useHotkeys(): HotkeyRegistry { const registry = useContext(RegistryContext) if (!registry) throw new Error("useHotkeys needs a <HotkeysProvider> above it") return registry } /** The same, or null without a provider, for components that treat hotkeys as optional. */ export function useMaybeHotkeys(): HotkeyRegistry | null { return useContext(RegistryContext) } /** * Ids the surrounding providers declare through `bindings` for one registry — the nearest * provider's unless another is given — visible during render before any effect registers them. * Returns an empty set without a provider. */ export function useDeclaredHotkeyIds(registry?: HotkeyRegistry | null): ReadonlySet<string> { const map = useContext(DeclaredContext) const nearest = useContext(RegistryContext) const key = registry ?? nearest return (key && map.get(key)) || EMPTY_DECLARED } export interface UseHotkeyOptions { /** False detaches the handler; the binding stays declared and listed. */ enabled?: boolean } /** * Attach a handler to a declared binding while this component is mounted. Inside a `HotkeyScope` of * the binding's own scope, the handler answers only for events from that scope element, so two * instances of a panel each get their own keys. */ export function useHotkey(id: string, handler: HotkeyHandler, options: UseHotkeyOptions = {}): void { const registry = useHotkeys() const scope = useContext(ScopeContext) const enabled = options.enabled !== false const latest = useRef(handler) // A commit no key caused, such as a push that revokes an action, runs every layout effect before the passive ones, // and a key dispatched in between must reach the handler that commit rendered, or none. So the handler is swapped // before any layout effect runs, and the binding detaches in the commit that disables or unmounts it. useInsertionEffect(() => { latest.current = handler }) useLayoutEffect(() => { if (!enabled) return return registry.bind(id, (event) => latest.current(event), scope) }, [registry, id, enabled, scope]) } /** Every binding with its keys in force, for a help overlay or a settings screen. Re-renders on declare, remap, and reset, and when a handler attaches or detaches. */ export function useHotkeyList(): readonly HotkeyEntry[] { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.list, registry.list) } /** The chord typed so far (`"g"`), or null. For a "g …" hint in a status bar. */ export function usePendingChord(): string | null { const registry = useHotkeys() return useSyncExternalStore(registry.subscribe, registry.pending, registry.pending) } /** * The nearest HotkeyScope's element getter, or null outside one. Hand it to a scoped * palette action's `within`, so the panel that answers is the one containing the * captured focus — the scope element itself included, since clicking a panel focuses it. */ export function useHotkeyScope(): (() => Element | null) | null { const scope = useContext(ScopeContext) return scope ? scope.element : null } export interface HotkeyScopeProps extends Omit<ComponentProps<"div">, "ref"> { scope: HotkeyScopeName } /** * Marks a subtree as a scope. Keys pressed with focus inside it reach this scope's bindings first. * It takes focus on click (`tabIndex={-1}`) so that clicking a panel is enough to aim the keyboard at it. */ export function HotkeyScope({ scope, children, ...props }: HotkeyScopeProps) { const [element, setElement] = useState<HTMLDivElement | null>(null) const value = useMemo<HandlerScope>(() => ({ scope, element: () => element }), [scope, element]) return ( <ScopeContext.Provider value={value}> <div tabIndex={-1} {...props} ref={setElement} data-hotkey-scope={scope}> {children} </div> </ScopeContext.Provider> ) }lib/hotkeys.ts // A hotkey registry: bindings declared as data, one keydown listener, scopes read from the DOM. // // A binding is an id, keys, a scope, and a description. It is declared once, so the same list feeds // the dispatcher, a command palette's shortcut column, and a help overlay, and a remap changes all // three. Handlers attach separately and come and go with the components that own them, which is how // two instances of one panel share a binding: the handler whose scope element holds the event wins. // // Scope is where the key came from. The chain runs from the event target outward through every // `[data-hotkey-scope]` ancestor, then `editing`, then `global`, and the innermost match wins. // Typing is protected: inside an input only `editing` bindings run. Menus and listboxes own every // key while they have focus. A dialog is a wall while focus is inside it: only scopes declared // inside it are active. Keys typed in the gap before a dialog takes focus land on the page, // where single-key bindings still run — the command palette's early capture shows the fix. export type Platform = "mac" | "other" /** `global` runs anywhere outside inputs. `editing` runs anywhere, inputs included. `panel:<id>` runs inside `[data-hotkey-scope="panel:<id>"]`. */ export type HotkeyScopeName = "global" | "editing" | `panel:${string}` export type HotkeyHandler = (event: KeyboardEvent) => void export interface HotkeyBinding { id: string /** `"mod+k"`, `"shift+/"`, `"?"`, or a chord of steps separated by spaces, `"g h"`. `mod` is ⌘ on a Mac and Ctrl elsewhere. `""` is unbound. */ keys: string scope: HotkeyScopeName description: string /** A heading for overlays and palette rows. */ group?: string /** Checked at keydown. False skips this binding and lets an outer scope answer. */ when?: () => boolean /** Fire again while the key is held. Off by default: a held key should not cancel thirty orders. */ repeat?: boolean /** On by default. */ preventDefault?: boolean } export interface HotkeyEntry extends HotkeyBinding { /** The keys in force: the override when there is a readable one, else the binding's own. */ keys: string defaultKeys: string /** The keys exactly as the binding declared them, before normalization: `"mod+k"` stays `"mod+k"`. */ declaredKeys: string /** True while an override is stored for the id, one that fails to parse or matches the defaults included. */ remapped: boolean } export interface HotkeyConflict { /** `duplicate`: same keys where both can fire. `prefix`: one is the start of the other's chord. `shadow`: a panel binding hides a global or editing one while focus is in that panel. */ kind: "duplicate" | "prefix" | "shadow" /** The contested keys, normalized; for a prefix, the shorter of the two. */ keys: string ids: [string, string] } /** Binding id to keys. What a consumer persists. */ export type HotkeyOverrides = Record<string, string> export type HotkeyTarget = Pick<EventTarget, "addEventListener" | "removeEventListener"> /** Limits a handler to events from inside one scope element, when the binding lives in that scope. */ export interface HandlerScope { scope: string element: () => Element | null } export interface HotkeyRegistryOptions { /** How long a chord waits for its next key. Default 1000. */ chordTimeoutMs?: number platform?: Platform } export interface HotkeyRegistry { readonly platform: Platform /** Declare a binding, or replace the one with the same id. Returns the conflicts it takes part in. */ register(binding: HotkeyBinding, handler?: HotkeyHandler): HotkeyConflict[] unregister(id: string): void /** * Declare a component's built-in binding. It lists and dispatches like a registration until a * consumer registers the id — the registration shadows it, and unregistering brings it back — * and it stands while any declarer holds it. Returns the release. */ declareDefault(binding: HotkeyBinding): () => void /** Attach a handler to a declared (or not yet declared) binding. Returns the detach. A fenced handler can settle a conflict, so attaching and detaching wake `subscribe` as a declaration does. */ bind(id: string, handler: HotkeyHandler, within?: HandlerScope | null): () => void /** Override a binding's keys; `""` unbinds it. Notifies `onChange`. Returns the conflicts the new keys take part in. */ remap(id: string, keys: string): HotkeyConflict[] /** Drop one override, or all of them. Notifies `onChange`. */ reset(id?: string): void /** Replace every override, from the consumer's persistence. Ids not declared yet apply when they are. Does not notify `onChange`. */ load(overrides: HotkeyOverrides): void overrides(): HotkeyOverrides /** Overrides changed through `remap` or `reset`: the moment to persist. */ onChange(cb: (overrides: HotkeyOverrides) => void): () => void /** Every binding with its keys in force. Stable array reference until a binding, an override, or a handler changes. */ list(): readonly HotkeyEntry[] /** Every conflict the declarations leave standing once their handlers are read, against the document as it is now. A fresh array each call. */ conflicts(): HotkeyConflict[] /** Wakes on any change to `list()` or `pending()`, and when a handler comes or goes. */ subscribe(cb: () => void): () => void /** Listen for keydown on a target, `document` by default. Attaching the same target twice adds one listener. */ attach(target?: HotkeyTarget): () => void /** The chord typed so far, normalized (`"g"`), or null. */ pending(): string | null /** Run one event through the dispatcher. `attach` calls this; it is public for events forwarded from elsewhere. True when the event was consumed. */ handle(event: KeyboardEvent): boolean } interface Step { ctrl: boolean alt: boolean shift: boolean meta: boolean key: string } const KEY_ALIASES: Record<string, string> = { " ": "space", spacebar: "space", esc: "escape", return: "enter", del: "delete", ins: "insert", arrowup: "up", arrowdown: "down", arrowleft: "left", arrowright: "right", pgup: "pageup", pgdn: "pagedown", plus: "+", } const CODE_KEYS: Record<string, string> = { Slash: "/", Backslash: "\\", Period: ".", Comma: ",", Semicolon: ";", Quote: "'", BracketLeft: "[", BracketRight: "]", Minus: "-", Equal: "=", Backquote: "`", } const IGNORED_KEYS = new Set(["Shift", "Control", "Alt", "Meta", "AltGraph", "CapsLock", "OS", "Dead", "Process", "Unidentified"]) const NON_TEXT_INPUTS = new Set(["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"]) const SCOPE_ATTR = "data-hotkey-scope" const WALLS = '[role="dialog"], [role="alertdialog"]' const MAC_LABELS = { ctrl: "⌃", alt: "⌥", shift: "⇧", meta: "⌘" } const OTHER_LABELS = { ctrl: "Ctrl", alt: "Alt", shift: "Shift", meta: "Win" } const KEY_LABELS: Record<string, string> = { enter: "↵", escape: "Esc", space: "Space", tab: "Tab", backspace: "⌫", delete: "Del", insert: "Ins", up: "↑", down: "↓", left: "←", right: "→", pageup: "PgUp", pagedown: "PgDn", home: "Home", end: "End", } export function detectPlatform(): Platform { if (typeof navigator === "undefined") return "other" const nav = navigator as Navigator & { userAgentData?: { platform?: string } } return /mac|iphone|ipad|ipod/i.test(nav.userAgentData?.platform ?? nav.platform ?? "") ? "mac" : "other" } function parseStep(part: string, platform: Platform): Step { const step: Step = { ctrl: false, alt: false, shift: false, meta: false, key: "" } // The canonical form writes the + key as itself, so "ctrl++" and a bare "+" must read back. // Only the canonical shape qualifies: a malformed "ctrl+++" still fails instead of quietly // becoming a live binding from a corrupted stored override. let rest = part let plusKey = false if (rest === "+") { plusKey = true rest = "" } else if (rest.endsWith("++") && rest.length > 2 && rest.slice(0, -2).split("+").every(Boolean)) { plusKey = true rest = rest.slice(0, -2) } for (const token of rest ? rest.toLowerCase().split("+") : []) { if (token === "mod") step[platform === "mac" ? "meta" : "ctrl"] = true else if (token === "ctrl" || token === "control") step.ctrl = true else if (token === "alt" || token === "option" || token === "opt") step.alt = true else if (token === "shift") step.shift = true else if (token === "meta" || token === "cmd" || token === "command" || token === "win" || token === "super") step.meta = true else if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) else step.key = KEY_ALIASES[token] ?? token } if (plusKey) { if (step.key) throw new Error(`hotkeys: "${part}" names two keys; separate the steps of a chord with a space`) step.key = "+" } if (!step.key) throw new Error(`hotkeys: "${part}" has no key (write the + key as "plus")`) return step } function parseKeys(keys: string, platform: Platform): Step[] { return keys.trim().split(/\s+/).filter(Boolean).map((part) => parseStep(part, platform)) } function stepToString(step: Step): string { return [step.ctrl && "ctrl", step.alt && "alt", step.shift && "shift", step.meta && "meta", step.key].filter(Boolean).join("+") } /** Canonical form for comparing and storing: `mod` resolved, aliases folded, modifiers in one order. Throws on keys that do not parse. */ export function normalizeKeys(keys: string, platform: Platform = detectPlatform()): string { return parseKeys(keys, platform).map(stepToString).join(" ") } /** Key caps for shadcn's `Kbd`, one array per chord step: `"mod+k"` is `[["⌘", "K"]]` on a Mac and `[["Ctrl", "K"]]` elsewhere. */ export function formatKeys(keys: string, platform: Platform = detectPlatform()): string[][] { const labels = platform === "mac" ? MAC_LABELS : OTHER_LABELS return parseKeys(keys, platform).map((step) => { const caps: string[] = [] if (step.ctrl) caps.push(labels.ctrl) if (step.alt) caps.push(labels.alt) if (step.shift) caps.push(labels.shift) if (step.meta) caps.push(labels.meta) caps.push(KEY_LABELS[step.key] ?? (/^(?:[a-z]|f\d{1,2})$/.test(step.key) ? step.key.toUpperCase() : step.key)) return caps }) } const isSymbol = (key: string) => key.length === 1 && !/[a-z0-9]/.test(key) function codeKey(code: string): string | null { if (/^Key[A-Z]$/.test(code)) return code.slice(3).toLowerCase() if (/^Digit\d$/.test(code)) return code.slice(5) return CODE_KEYS[code] ?? null } function matchesStep(step: Step, event: KeyboardEvent): boolean { if (event.ctrlKey !== step.ctrl || event.altKey !== step.alt || event.metaKey !== step.meta) return false const pressed = event.key.toLowerCase() const key = KEY_ALIASES[pressed] ?? pressed // "?" already says shift on the layouts that need it and nothing on the ones that do not. if (key === step.key) return isSymbol(key) && !step.shift ? true : event.shiftKey === step.shift // A modifier changed the character: shift+1 arrives as "!", option+k as "˚". Read the physical key, // unless the modifier is AltGr, which is how half of Europe types "@". if ((event.shiftKey || event.altKey) && isSymbol(key) && !event.getModifierState?.("AltGraph")) return event.shiftKey === step.shift && codeKey(event.code) === step.key return false } /** True for a single-step `keys` this event satisfies. For a component that must answer a binding itself, behind a wall the dispatcher will not cross. */ export function matchesKeys(event: KeyboardEvent, keys: string, platform: Platform = detectPlatform()): boolean { const steps = parseKeys(keys, platform) return steps.length === 1 && matchesStep(steps[0]!, event) } /** The step a keydown spells, in the form `remap` takes, or null for a bare modifier. For a "press the new shortcut" field. */ export function keysFromEvent(event: KeyboardEvent): string | null { if (IGNORED_KEYS.has(event.key)) return null const pressed = event.key.toLowerCase() let key = KEY_ALIASES[pressed] ?? pressed let shift = event.shiftKey if (isSymbol(key)) { // option+k arrives as "˚": store the physical key. A shifted symbol such as "?" already carries its shift. const physical = event.altKey && !event.getModifierState?.("AltGraph") ? codeKey(event.code) : null if (physical) key = physical else shift = false } return stepToString({ ctrl: event.ctrlKey, alt: event.altKey, shift, meta: event.metaKey, key }) } // Duck-typed so a target from a popout window's realm still counts. function asElement(target: EventTarget | null): Element | null { const el = target as Element | null return el && typeof el.closest === "function" ? el : null } /** Text inputs, textareas, selects, and anything contenteditable. A checkbox is not typing. */ export function isEditableTarget(target: EventTarget | null): boolean { const el = asElement(target) if (!el) return false if (el.tagName === "INPUT") return !NON_TEXT_INPUTS.has((el.getAttribute("type") ?? "text").toLowerCase()) if (el.tagName === "TEXTAREA" || el.tagName === "SELECT") return true return el.closest('[contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null } /** Inside a menu, menubar, or listbox, which own arrows, letters, Enter, and Escape while focused. */ export function isMenuTarget(target: EventTarget | null): boolean { return asElement(target)?.closest('[role="menu"], [role="menubar"], [role="listbox"]') != null } /** The scopes active for an event target, innermost first. Inside a dialog only the scopes declared within it count. */ export function scopeChain(target: EventTarget | null): string[] { const chain: string[] = [] let el = asElement(target) const wall = el?.closest(WALLS) ?? null while (el) { const hit = el.closest(`[${SCOPE_ATTR}]`) if (!hit || (wall && !wall.contains(hit))) break const name = hit.getAttribute(SCOPE_ATTR) if (name && !chain.includes(name)) chain.push(name) el = hit.parentElement } if (!wall) for (const name of ["editing", "global"]) if (!chain.includes(name)) chain.push(name) return chain } interface Rec { binding: HotkeyBinding steps: Step[] sequence: string[] handler?: HotkeyHandler } interface Bound { handler: HotkeyHandler within: HandlerScope | null } const isAmbient = (scope: string) => scope === "global" || scope === "editing" function sameBinding(a: HotkeyBinding, b: HotkeyBinding): boolean { return a.keys === b.keys && a.scope === b.scope && a.description === b.description && a.group === b.group && a.when === b.when && a.repeat === b.repeat && a.preventDefault === b.preventDefault } function conflictBetween(a: Rec, b: Rec): HotkeyConflict | null { if (!a.sequence.length || !b.sequence.length) return null const [short, long] = a.sequence.length <= b.sequence.length ? [a, b] : [b, a] if (!short.sequence.every((step, i) => long.sequence[i] === step)) return null const sa = a.binding.scope const sb = b.binding.scope const together = sa === sb || (isAmbient(sa) && isAmbient(sb)) if (!together && !isAmbient(sa) && !isAmbient(sb)) return null const kind = !together ? "shadow" : short.sequence.length === long.sequence.length ? "duplicate" : "prefix" return { kind, keys: short.sequence.join(" "), ids: [a.binding.id, b.binding.id] } } export function createHotkeyRegistry(options: HotkeyRegistryOptions = {}): HotkeyRegistry { const platform = options.platform ?? detectPlatform() // A timer runs at once for 0, NaN, Infinity, or anything past 2^31 - 1 ms, and a chord could never finish: those read as the default. const given = options.chordTimeoutMs const chordTimeoutMs = typeof given === "number" && given >= 1 && given <= 2_147_483_647 ? given : 1000 const recs = new Map<string, Rec>() // Component defaults: each declaration is held individually beside the live records, installed // only while no consumer registration shadows the id — the earliest held declaration is the // one in force. Tokens, not the bindings, carry identity: two declarers may pass one shared // module constant. `defaultIds` marks the ids whose live record is the default. const defaults = new Map<string, { binding: HotkeyBinding }[]>() const defaultIds = new Set<string>() const overrides = new Map<string, string>() const bound = new Map<string, Bound[]>() const listeners = new Set<() => void>() const changeListeners = new Set<(overrides: HotkeyOverrides) => void>() const attached = new Map<HotkeyTarget, { count: number; listener: (event: Event) => void }>() let snapshot: readonly HotkeyEntry[] | null = null // Chord state: how many steps of which bindings have matched so far. let progress = new Map<string, number>() let pendingSteps: string[] = [] let timer: ReturnType<typeof setTimeout> | null = null function emit() { snapshot = null for (const cb of listeners) cb() } function notifyChange() { const current = Object.fromEntries(overrides) for (const cb of changeListeners) cb(current) } function clearPending(): boolean { if (timer) clearTimeout(timer) timer = null if (!pendingSteps.length) return false progress = new Map() pendingSteps = [] return true } function build(binding: HotkeyBinding, handler?: HotkeyHandler): Rec { const declaredSteps = parseKeys(binding.keys, platform) let steps = declaredSteps const override = overrides.get(binding.id) if (override !== undefined) { // A stale override from storage must not take the app down: fall back to the default. try { steps = parseKeys(override, platform) } catch { steps = declaredSteps } } return { binding, steps, sequence: steps.map(stepToString), handler } } function rebuild(id?: string) { for (const [key, rec] of recs) if (id === undefined || key === id) recs.set(key, build(rec.binding, rec.handler)) clearPending() emit() } // The elements a binding's handlers are fenced to, in the binding's own scope; null when any handler runs // unfenced, empty when nothing is bound. Mirrors what handlerFor lets through. function fencesOf(rec: Rec): (Element | null)[] | null { if (rec.handler) return null const out: (Element | null)[] = [] for (const { within } of bound.get(rec.binding.id) ?? []) { if (!within || within.scope !== rec.binding.scope) return null out.push(within.element()) } return out } // Two bindings whose every handler is fenced to boxes that do not hold one another cannot meet on one key, // which is how an order ticket and an RFQ ticket on one desk share mod+enter. Declarations with no handler, // or a handler that runs anywhere, are compared as declared. function apart(a: Rec, b: Rec): boolean { const fa = fencesOf(a) const fb = fencesOf(b) if (!fa?.length || !fb?.length) return false for (const ea of fa) for (const eb of fb) if (ea && eb && (ea === eb || ea.contains(eb) || eb.contains(ea))) return false return true } function conflictsFor(id: string): HotkeyConflict[] { const rec = recs.get(id) if (!rec) return [] const out: HotkeyConflict[] = [] for (const other of recs.values()) { const c = other === rec ? null : conflictBetween(rec, other) if (c && !apart(rec, other)) out.push(c) } return out } function handlerFor(rec: Rec, el: Element | null): HotkeyHandler | null { const list = bound.get(rec.binding.id) ?? [] for (let i = list.length - 1; i >= 0; i--) { const { handler, within } = list[i]! if (!within || within.scope !== rec.binding.scope) return handler const scopeEl = within.element() if (el && scopeEl?.contains(el)) return handler } return rec.handler ?? null } function attempt(event: KeyboardEvent, chain: string[], editable: boolean, el: Element | null): boolean { let complete: { rank: number; rec: Rec; handler: HotkeyHandler } | null = null let advanceRank = Infinity let advanceStep = "" const advanced = new Map<string, number>() for (const rec of recs.values()) { const at = pendingSteps.length ? progress.get(rec.binding.id) : 0 const step = at === undefined ? undefined : rec.steps[at] if (at === undefined || !step || !matchesStep(step, event)) continue const rank = chain.indexOf(rec.binding.scope) if (rank < 0 || (editable && rec.binding.scope !== "editing")) continue if (event.repeat && !rec.binding.repeat) continue if (rec.binding.when && !rec.binding.when()) continue const handler = handlerFor(rec, el) if (!handler) continue if (at === rec.steps.length - 1) { if (!complete || rank < complete.rank) complete = { rank, rec, handler } } else { advanced.set(rec.binding.id, at + 1) if (rank < advanceRank) { advanceRank = rank advanceStep = stepToString(step) } } } // The innermost scope decides. A chord that started closer to the target outranks a finished binding further out. if (complete && complete.rank <= advanceRank) { if (clearPending()) emit() if (complete.rec.binding.preventDefault !== false) event.preventDefault() complete.handler(event) return true } if (advanced.size) { if (timer) clearTimeout(timer) progress = advanced pendingSteps = [...pendingSteps, advanceStep] timer = setTimeout(() => { timer = null if (clearPending()) emit() }, chordTimeoutMs) event.preventDefault() emit() return true } return false } function handle(event: KeyboardEvent): boolean { // 229 is a key an input method takes, which Safari sends before isComposing says so. if (event.defaultPrevented || event.isComposing || event.keyCode === 229 || IGNORED_KEYS.has(event.key)) return false const target = event.target if (isMenuTarget(target)) { if (clearPending()) emit() return false } if (pendingSteps.length && event.key === "Escape") { clearPending() event.preventDefault() emit() return true } const chain = scopeChain(target) const editable = isEditableTarget(target) const el = asElement(target) if (attempt(event, chain, editable, el)) return true // Not a continuation of the chord in flight: drop the chord and read the key on its own. if (clearPending()) { emit() return attempt(event, chain, editable, el) } return false } return { platform, register(binding, handler) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") const previous = recs.get(binding.id) // Declaring the same thing again (a remount, a hot reload) is not a change — though a // consumer registering over a default claims the id either way. if (previous && !handler && sameBinding(previous.binding, binding)) { defaultIds.delete(binding.id) return conflictsFor(binding.id) } // Built before any marker moves: a throw on malformed keys must leave ownership untouched. const next = build(binding, handler ?? previous?.handler) defaultIds.delete(binding.id) recs.set(binding.id, next) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { // A default is not removable here — only shadowed or released; `remap(id, "")` unbinds // its keys. Removing-and-reinstalling would wake subscribers with an unchanged list, and // a subscriber that unregisters whatever it sees listed would recurse forever. if (!recs.has(id) || defaultIds.has(id)) return // The earliest held default resurfaces when the consumer registration leaves — replaced // in place, since the record's position carries conflict resolution and list order. const held = defaults.get(id) if (held?.[0]) { recs.set(id, build(held[0].binding)) defaultIds.add(id) } else { recs.delete(id) } clearPending() emit() }, declareDefault(binding) { if (!binding.id) throw new Error("hotkeys: a binding needs an id") // Built before the declaration is held — shadowed or not — so malformed keys throw here // and never wait inside the held list to break a later resurface. const rec = build(binding) const installing = !recs.has(binding.id) const token = { binding } const held = defaults.get(binding.id) if (held) held.push(token) else defaults.set(binding.id, [token]) if (installing) { recs.set(binding.id, rec) defaultIds.add(binding.id) clearPending() emit() } return () => { const current = defaults.get(binding.id) if (!current) return const at = current.indexOf(token) if (at < 0) return current.splice(at, 1) if (current.length === 0) defaults.delete(binding.id) if (!defaultIds.has(binding.id)) return const next = current[0] if (next === undefined) { defaultIds.delete(binding.id) recs.delete(binding.id) clearPending() emit() } else if (!sameBinding(recs.get(binding.id)!.binding, next.binding)) { // A remaining declarer's own binding takes over from the released one's. recs.set(binding.id, build(next.binding)) clearPending() emit() } } }, bind(id, handler, within = null) { const entry: Bound = { handler, within } const list = bound.get(id) if (list) list.push(entry) else bound.set(id, [entry]) // A fenced handler can settle a conflict, so a settings screen wants to hear about it. emit() return () => { const current = bound.get(id) if (!current) return const i = current.indexOf(entry) if (i < 0) return current.splice(i, 1) if (!current.length) bound.delete(id) emit() } }, remap(id, keys) { const rec = recs.get(id) if (!rec) throw new Error(`hotkeys: cannot remap "${id}", no such binding`) const next = normalizeKeys(keys, platform) if (next === normalizeKeys(rec.binding.keys, platform)) overrides.delete(id) else overrides.set(id, next) rebuild(id) notifyChange() return conflictsFor(id) }, reset(id) { if (id === undefined) overrides.clear() else overrides.delete(id) rebuild(id) notifyChange() }, load(next) { overrides.clear() for (const [id, keys] of Object.entries(next)) overrides.set(id, keys) rebuild() }, overrides: () => Object.fromEntries(overrides), onChange(cb) { changeListeners.add(cb) return () => changeListeners.delete(cb) }, list() { return (snapshot ??= [...recs.values()].map((rec) => { const keys = rec.sequence.join(" ") const defaultKeys = normalizeKeys(rec.binding.keys, platform) // Remapped means an override is stored, not that the resolved keys differ: a corrupt // override falls back to the default keys, and Reset must still be able to clear it. return { ...rec.binding, keys, defaultKeys, declaredKeys: rec.binding.keys, remapped: overrides.has(rec.binding.id) } })) }, conflicts() { const all = [...recs.values()] const out: HotkeyConflict[] = [] for (let i = 0; i < all.length; i++) { for (let j = i + 1; j < all.length; j++) { const c = conflictBetween(all[i]!, all[j]!) if (c && !apart(all[i]!, all[j]!)) out.push(c) } } return out }, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, attach(target) { const t = target ?? (typeof document === "undefined" ? null : document) if (!t) return () => {} let entry = attached.get(t) if (!entry) { entry = { count: 0, listener: (event) => void handle(event as KeyboardEvent) } t.addEventListener("keydown", entry.listener) attached.set(t, entry) } entry.count++ let done = false return () => { if (done) return done = true entry.count-- if (entry.count > 0) return t.removeEventListener("keydown", entry.listener) attached.delete(t) } }, pending: () => (pendingSteps.length ? pendingSteps.join(" ") : null), handle, } }
Usage
import { CommandGroup } from "@/components/ui/command"
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import {
CommandPalette,
CommandPaletteContent,
CommandPaletteDialog,
CommandPaletteEmpty,
CommandPaletteInput,
CommandPaletteItem,
CommandPaletteList,
CommandPaletteResults,
createActionRegistry,
} from "@/components/ui/command-palette"
export default function CommandPaletteDemo() {
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState("None")
const [actions] = useState(() => {
const registry = createActionRegistry()
registry.register([
{ id: "orders", title: "Show orders", run: () => setSelected("Show orders") },
{ id: "positions", title: "Show positions", run: () => setSelected("Show positions") },
])
return registry
})
return (
<HotkeysProvider>
<div className="flex min-h-80 w-fit max-w-full flex-col justify-center gap-3 text-sm">
<button type="button" className="self-start rounded border border-border px-3 py-2 hover:bg-muted" onClick={() => setOpen(true)}>Open commands</button>
<p role="status">Selected: {selected}</p>
<CommandPalette actions={actions} open={open} onOpenChange={setOpen}>
<CommandPaletteDialog>
<CommandPaletteContent>
<CommandPaletteInput />
<CommandPaletteList>
<CommandPaletteEmpty>No results</CommandPaletteEmpty>
<CommandPaletteResults>
{(group) => (
<CommandGroup heading={group.heading}>
{(group.id === "recent" ? group.rows.slice(0, 5) : group.rows).map((row) => (
<CommandPaletteItem key={row.key} row={row}>{row.title}</CommandPaletteItem>
))}
</CommandGroup>
)}
</CommandPaletteResults>
</CommandPaletteList>
</CommandPaletteContent>
</CommandPaletteDialog>
</CommandPalette>
</div>
</HotkeysProvider>
)
}
Open the dialog and choose an action. The caption shows which callback ran; replace those callbacks with navigation or another application action. HotkeysProvider also enables the default opening shortcut: ⌘K on macOS, Ctrl+K elsewhere. Focus the preview before trying it here.
CommandPalette coordinates behavior and requires children. CommandPaletteResults provides groups; the callback owns their markup. Use ordinary shadcn CommandGroup and CommandShortcut where needed. The examples cap the recent group at five rows.
Keep the action registry stable for the lifetime of the view. This example creates and populates its own registry in a lazy state initializer; scoped actions show registration with unmount cleanup.
Inline commands
Use variant="go-bar" for an always-visible command line. Focus it, or press / while focus is in this preview and outside a text field. Type AAPL to see its functions, then AAPL G to narrow to the price chart command. Enter selects it; Escape clears and blurs the input.
The grammar accepts AAPL, MSFT, or ZN, followed by DES or GP. It reports the selected command below the input. These rows come from goBarGrammar, so they do not require asynchronous symbol search or enter recents. This layout reads groups with useCommandPalette(), puts each description below its title, and adds application help beneath the input. It uses the same input and item parts as the dialog; focus, opening, and closing follow the Go-bar rules.
import { CommandGroup } from "@/components/ui/command"
import { useId, useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, CommandPaletteContent, CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteList, useCommandPalette, createActionRegistry, type PaletteAction } from "@/components/ui/command-palette"
const SYMBOLS = new Set(["AAPL", "MSFT", "ZN"])
const FUNCTIONS = [
{ code: "DES", title: "Description" },
{ code: "GP", title: "Price chart" },
]
export default function CommandPaletteGoBarDemo() {
const helpId = useId()
const [actions] = useState(() => createActionRegistry())
const [command, setCommand] = useState("None")
const grammar = (input: string): PaletteAction[] => {
const [symbol, code = ""] = input.toUpperCase().split(/\s+/)
if (!symbol || !SYMBOLS.has(symbol)) return []
return FUNCTIONS.filter((fn) => fn.code.startsWith(code)).map((fn) => ({
id: `${symbol}.${fn.code}`,
title: `${symbol} ${fn.code}`,
subtitle: fn.title,
run: () => setCommand(`${symbol} ${fn.code}`),
}))
}
return (
<HotkeysProvider>
<div className="min-h-64 w-md max-w-full space-y-3 text-sm">
<CommandPalette variant="go-bar" actions={actions} goBarGrammar={grammar} labels={{ title: "Instrument functions" }}>
<CommandPaletteContent>
<p className="px-2 pb-2 text-xs font-medium">Instrument functions</p>
<CommandPaletteInput aria-describedby={helpId} />
<p id={helpId} className="px-3 py-2 text-xs text-muted-foreground">DES: description · GP: price chart</p>
<CommandPaletteList className="mt-0"><PaletteResults /></CommandPaletteList>
</CommandPaletteContent>
</CommandPalette>
<p role="status">Command: {command}</p>
</div>
</HotkeysProvider>
)
}
function PaletteResults() {
const { groups } = useCommandPalette()
return (
<>
<CommandPaletteEmpty>Type a symbol and function.</CommandPaletteEmpty>
{groups.map((group) => (
<CommandGroup key={group.id} heading={group.heading}>
{group.rows.map((row) => (
<CommandPaletteItem key={row.key} row={row} className="items-start py-3">
<span className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{row.title}</span>
<span className="text-muted-foreground">{row.subtitle}</span>
</span>
</CommandPaletteItem>
))}
</CommandGroup>
))}
</>
)
}
Symbol search
Supply a SymbolSearchAdapter for asynchronous lookup, created once and kept stable — an adapter rebuilt on every parent render restarts its search each time. Here, two or more letters search three local symbols after a short delay, making the searching state visible. Try AA, MS, or ZN. Changing the query cancels the pending lookup; the adapter clears its timer when aborted.
Enter selects the highlighted symbol. Shift+Enter, or clicking its Watch hint, requests watching it instead. The caption shows which callback ran. Replace the delayed local lookup with your symbol service and pass the supplied abort signal to the request.
import { CommandGroup, CommandShortcut } from "@/components/ui/command"
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, CommandPaletteContent, CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteKeys, CommandPaletteList, CommandPaletteResults, CommandPaletteSecondary, useCommandPalette, createActionRegistry, type SymbolResult, type SymbolSearchAdapter } from "@/components/ui/command-palette"
const SYMBOLS: SymbolResult[] = [
{ symbol: "AAPL", name: "Apple" },
{ symbol: "MSFT", name: "Microsoft" },
{ symbol: "ZN", name: "T-Note" },
]
const symbols: SymbolSearchAdapter = {
minLength: 2,
debounceMs: 150,
search: (query, signal) => new Promise((resolve, reject) => {
signal.throwIfAborted()
// A delayed local lookup stands in for a request to your symbol service.
const timer = setTimeout(() => {
const q = query.toLowerCase()
resolve(SYMBOLS.filter((item) => item.symbol.toLowerCase().startsWith(q) || item.name?.toLowerCase().includes(q)))
}, 350)
signal.addEventListener("abort", () => {
clearTimeout(timer)
reject(new DOMException("Aborted", "AbortError"))
}, { once: true })
}),
}
export default function CommandPaletteSymbolsDemo() {
const [actions] = useState(() => createActionRegistry())
const [open, setOpen] = useState(false)
const [result, setResult] = useState("No symbol selected.")
return (
<HotkeysProvider>
<div className="flex min-h-80 w-fit max-w-full flex-col justify-center gap-3 text-sm">
<button type="button" className="self-start rounded border border-border px-3 py-2 hover:bg-muted" onClick={() => setOpen(true)}>Find a symbol</button>
<p role="status">{result}</p>
<CommandPalette
actions={actions}
open={open}
onOpenChange={setOpen}
symbols={symbols}
onSymbolSelect={(symbol) => setResult(`Selected: ${symbol.symbol}`)}
symbolSecondary={{ title: "Watch", run: (symbol) => setResult(`Watch requested: ${symbol.symbol}`) }}
labels={{ title: "Find a symbol", placeholder: "Search at least two letters…" }}
>
<CommandPaletteDialog>
<CommandPaletteContent>
<CommandPaletteInput />
<CommandPaletteList><PaletteResults /></CommandPaletteList>
</CommandPaletteContent>
</CommandPaletteDialog>
</CommandPalette>
</div>
</HotkeysProvider>
)
}
function PaletteResults() {
const { loading } = useCommandPalette()
return (
<>
<CommandPaletteEmpty>{loading ? "Searching…" : "No matching symbols"}</CommandPaletteEmpty>
<CommandPaletteResults>
{(group) => (
<CommandGroup heading={group.heading}>
{(group.id === "recent" ? group.rows.slice(0, 5) : group.rows).map((row) => (
<CommandPaletteItem key={row.key} row={row}>
<span className="truncate">{row.title}</span>
{row.subtitle && <span className="truncate text-muted-foreground">{row.subtitle}</span>}
{row.badge && <span className="rounded border border-border px-1 text-xs uppercase">{row.badge}</span>}
{(row.secondary || row.keys) && (
<CommandShortcut className="flex shrink-0 items-center gap-2 text-xs tracking-normal">
<CommandPaletteSecondary><CommandPaletteKeys keys="shift+enter" />{row.secondary?.title}</CommandPaletteSecondary>
{row.keys && <CommandPaletteKeys keys={row.keys} />}
</CommandShortcut>
)}
</CommandPaletteItem>
))}
</CommandGroup>
)}
</CommandPaletteResults>
</>
)
}
Scoped and secondary actions
Open global commands to see Show help. Open book commands to also see Refresh book, because that button sits inside the book's hotkey scope. The palette captures the scope before taking focus. Enter on Refresh book increments its counter; Shift+Enter, or clicking Reset, resets it instead.
With focus on the book's button and the palette closed, press r to run the same refresh handler directly. The row gets its shortcut from bindingId. Book runs beneath HotkeyScope, and its effect returns the action registry's cleanup so unmounting removes the action.
import { CommandGroup, CommandShortcut } from "@/components/ui/command"
import { useCallback, useEffect, useState } from "react"
import { HotkeyScope, HotkeysProvider, useHotkey, useHotkeyScope } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import { CommandPalette, CommandPaletteContent, CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteKeys, CommandPaletteList, CommandPaletteResults, CommandPaletteSecondary, useCommandPalette, createActionRegistry, type ActionRegistry } from "@/components/ui/command-palette"
const BINDINGS: HotkeyBinding[] = [
{ id: "book.refresh", keys: "r", scope: "panel:book", description: "Refresh book" },
]
function Book({ actions, onOpen }: { actions: ActionRegistry; onOpen: () => void }) {
const [requests, setRequests] = useState(0)
const refresh = useCallback(() => setRequests((count) => count + 1), [])
// The scope element is the instance fence: with several books sharing this scope,
// the palette runs the one that contained the captured focus.
const within = useHotkeyScope()
useHotkey("book.refresh", refresh)
useEffect(() => actions.register({
id: "book.refresh",
title: "Refresh book",
scope: "panel:book",
bindingId: "book.refresh",
within,
run: refresh,
secondary: { title: "Reset", run: () => setRequests(0) },
}), [actions, refresh, within])
return (
<div className="space-y-3 rounded border border-border p-3">
<p className="font-medium">Order book</p>
<button type="button" className="rounded border border-border px-2 py-1 hover:bg-muted" onClick={onOpen}>Open book commands</button>
<p role="status">Refresh requests: {requests}</p>
</div>
)
}
export default function CommandPaletteScopedActionsDemo() {
const [open, setOpen] = useState(false)
const [help, setHelp] = useState("No help request.")
const [actions] = useState(() => {
const registry = createActionRegistry()
registry.register({ id: "help", title: "Show help", run: () => setHelp("Help requested.") })
return registry
})
return (
<HotkeysProvider bindings={BINDINGS}>
<div className="flex min-h-80 w-sm max-w-full flex-col justify-center gap-3 text-sm lining-nums tabular-nums">
<button type="button" className="self-start rounded border border-border px-3 py-2 hover:bg-muted" onClick={() => setOpen(true)}>Open global commands</button>
<HotkeyScope scope="panel:book">
<Book actions={actions} onOpen={() => setOpen(true)} />
</HotkeyScope>
<p role="status">{help}</p>
<CommandPalette actions={actions} open={open} onOpenChange={setOpen}>
<CommandPaletteDialog>
<CommandPaletteContent>
<CommandPaletteInput />
<CommandPaletteList><PaletteResults /></CommandPaletteList>
</CommandPaletteContent>
</CommandPaletteDialog>
</CommandPalette>
</div>
</HotkeysProvider>
)
}
function PaletteResults() {
const { loading } = useCommandPalette()
return (
<>
<CommandPaletteEmpty>{loading ? "Searching…" : "No results"}</CommandPaletteEmpty>
<CommandPaletteResults>
{(group) => (
<CommandGroup heading={group.heading}>
{(group.id === "recent" ? group.rows.slice(0, 5) : group.rows).map((row) => (
<CommandPaletteItem key={row.key} row={row}>
<span className="truncate">{row.title}</span>
{row.subtitle && <span className="truncate text-muted-foreground">{row.subtitle}</span>}
{row.badge && <span className="rounded border border-border px-1 text-xs uppercase">{row.badge}</span>}
{(row.secondary || row.keys) && (
<CommandShortcut className="flex shrink-0 items-center gap-2 text-xs tracking-normal">
<CommandPaletteSecondary><CommandPaletteKeys keys="shift+enter" />{row.secondary?.title}</CommandPaletteSecondary>
{row.keys && <CommandPaletteKeys keys={row.keys} />}
</CommandShortcut>
)}
</CommandPaletteItem>
))}
</CommandGroup>
)}
</CommandPaletteResults>
</>
)
}
Shared recents
Pass the same action registry to both presentations to share actions and recent selections. Choose Show positions in the inline command line, then open the dialog: it appears under Recent. Select Show orders in the dialog and reopen the inline list to see the change there too.
This example keeps recents in memory for its lifetime. Recents describes the save and restore APIs for application-owned persistence.
API Reference
<CommandPalette>
actions- Registry created by
createActionRegistry(). children- Caller-owned dialog or inline content.
variant- Selects keyboard and focus behavior; it does not insert a dialog.
open- Controls the dialog or go-bar dropdown.
defaultOpen- Initial open state when uncontrolled.
onOpenChange- Receives requested open-state changes in either mode.
symbols- Supplies asynchronous symbol results.
onSymbolSelect- Runs for a symbol's primary selection.
symbolSecondary- Alternate symbol action; does not call
onSymbolSelect.Full type for symbolSecondary
{ title: string; run: (symbol: SymbolResult) => void } goBarGrammar- Synchronous command rows for nonempty input, in either variant.
Full type for goBarGrammar
(input: string) => readonly PaletteAction[] hotkeys- Supplies bindings and row shortcuts;
nullopts out. hotkey- Default opening or focus binding;
falsedisables its registration and handler. labels- Overrides accessible names, default input placeholder, group headings and binding text.
When open is supplied, update it in onOpenChange to accept requests to open or close. Without it, the component manages that state. Accept an open in the same update: the panel a popout's key came from is known only while that key is handled, and an open accepted later, in a transition or after an await, freezes the main window's focus instead and leaves the popout's keys to its bindings while the palette is open.
<CommandPaletteDialog>
Optional modal wrapper connected to the root's open state. Put one CommandPaletteContent inside. It forwards your shadcn CommandDialog props except managed open, defaultOpen and onOpenChange; children is required. title and description default to the root labels; className styles the modal content. Your installed dialog traps focus while open and determines where focus goes on close. Compose your own dialog when your application needs a different shell or different focus handling, using the root's controlled open and onOpenChange props.
<CommandPaletteContent>
Owns one query, action/recent/shortcut subscriptions, and symbol search. Mount one per root. It requires children and forwards Command props and refs except shouldFilter, which stays false so asynchronous results keep caller order. loop defaults to true; label defaults to labels.title. It adds lining and tabular figures and carries data-slot="tradecn-command-palette" and data-variant.
An onKeyDown or onBlur handler runs first; preventDefault() cancels the corresponding shared behavior. Application controls inside Content keep their own Enter and navigation keys; these stop before the command root. Use onKeyDownCapture to observe them at the root. Escape and the opening shortcut still reach shared close behavior. Input state lasts as long as Content stays mounted. A normal dialog unmounts it on close; inline blur retains the query. Selection and inline Escape clear it.
<CommandPaletteInput>
Forwards CommandInput props and its input ref except managed value, defaultValue, onValueChange and the primitive-owned onChange. Use onChangeCapture to observe native changes. Read or change the query through useCommandPalette(). placeholder defaults to labels.placeholder. Inline focus requests opening; your onFocus runs first and can cancel it with preventDefault(). Use one input per root.
<CommandPaletteList>
Forwards CommandList props and ref. In a go-bar it renders only while open, positions itself below Content, and prevents mouse down from blurring the input before selection. Override placement with className; place application controls outside the list when they need normal focus behavior. Dialog lists remain in normal flow.
<CommandPaletteResults>
Optional keyed group iterator. Its required child is (group: PaletteGroup) => ReactNode; each group gets a stable keyed fragment. The caller supplies CommandGroup, rows and their keys. For a different group order or a flat list, read groups from useCommandPalette() and map them directly. The underlying command follows the rendered row order.
idrecent,commands,symbols, oractions:followed by the group heading.heading- Localized group heading or the action's own group.
rows- Eligible rows in ranked or registration order.
<CommandPaletteItem>
Requires a row: PaletteRow and caller-owned children. Forwards CommandItem props and ref except managed value and onSelect. The primitive owns onClick and onPointerMove, so these props are excluded; use onClickCapture or onPointerMoveCapture and stopPropagation() to intercept them. It sets data-row={row.key} and selects through the shared behavior. The item's disabled prop prevents selection. Use row.key as the React key, and keep row keys unique within a rendered list.
key- Result identity; distinguishes registered, grammar, symbol and recent rows.
title- Action title or symbol. Render it or supply a meaningful accessible name.
subtitle- Action subtitle, or symbol name and exchange joined with
·. badge- Scope with
panel:removed, or symbol kind. keys- Current binding keys; pass to
CommandPaletteKeys. secondary- Alternate callback and text.
Full type for secondary
{ title: string; run: () => void } | undefined run- Raw callback. Use Item or
selectto retain query, closure and recent behavior. recent- Entry to remember after selection; null for grammar rows.
A removed result cannot run through select; it resolves the requested key against the current offered rows. Scope filtering controls discovery, not authorization. Action callbacks must enforce application permissions.
<CommandPaletteSecondary>
Optional native button inside an Item. Requires caller content and renders only when the row has a secondary action. It inherits a disabled Item, retains input focus on mouse down, and stops the click from selecting the primary action. Your onClick can cancel it with preventDefault().
It defaults to type="button", tabIndex={-1} and selected-row-only visibility. A disabled Secondary blocks both its click and Shift+Enter; a disabled Item blocks both actions. Shift+Enter invokes the same alternate action from the input, even when you omit the button. Supply a discoverable hint when offering secondary actions. The symbols and scoped-action examples show the composition.
Children of a listbox option are presentational to assistive technology, so the secondary button's disabled state is not announced. Include an unavailable cue in the row's text or accessible name when disabling its secondary action.
<CommandPaletteKeys>
Formats required keys: string as key caps. Accepts native span props and ref, excluding children. Optional platform: Platform overrides the nearest root's hotkey platform; outside a root it uses the formatter's platform detection. It creates no subscription.
<CommandPaletteEmpty>
Forwards CommandEmpty props and ref, with required caller-owned children. The command primitive shows it when no rendered items remain. Read loading to choose between a searching message and an empty message. It creates no live region; add role="status" if your application needs an announcement.
useCommandPalette()
Reads the nearest Content without starting subscriptions, timers or requests. Multiple readers share the same search.
groups- All eligible results. Callers choose group order, row markup and limits.
input,setInput- Raw query and setter; matching uses trimmed input.
loading- A qualifying symbol query has no current answer.
open,setOpen- Root state and a request to change it.
platform- The registry's hotkey platform: its configured value, else the detected one.
undefinedwithout a hotkey registry. select- Clears input, requests close, touches recents and invokes the current callback. Secondary defaults to false and falls back to primary if absent.
Full type for select
(row: PaletteRow, secondary?: boolean) => void
Use CommandPaletteItem for result selection and CommandPaletteSecondary for its alternate control; they also handle disabled state and events. Calling select yourself requires guarding your own control's disabled state. It does nothing while closed.
Actions
PaletteAction describes a registered action or a row returned by goBarGrammar.
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | Identity. Same-ID registrations stack; each unregisters only its own, and rows show one entry per ID and scope. |
title |
string |
Required | Row title and primary search text. |
run |
() => void |
Required | Primary action. |
subtitle |
string |
— | Secondary text exposed as row.subtitle for your markup; also searchable. |
scope |
string |
Everywhere | Hotkey scope required to offer a registered action. Exposed as row.badge, with panel: removed. |
within |
() => Element | null |
Latest registration | The panel instance that owns a scoped action: a getter for an element containing wherever focus rests in that panel — pass useHotkeyScope(), since clicking a panel focuses the scope element itself. With several panels sharing one scope, the containing instance runs. |
keywords |
readonly string[] |
— | Additional search terms. |
group |
string |
labels.actions |
Heading for registered actions. An explicitly supplied group is also searchable. |
bindingId |
string |
— | Hotkey binding whose current keys appear in row.keys. |
secondary |
{ title: string; run: () => void } |
— | Alternate action and its hint text. |
Enter or a row click runs the primary action. Shift+Enter runs secondary when present, otherwise the primary action. CommandPaletteSecondary supplies a clickable hint on the highlighted row; callers choose its content and placement. Secondary actions replace the primary callback; repeat any shared work yourself.
A registered action with scope: "panel:book" is offered only when the palette opens from inside <HotkeyScope scope="panel:book">. It captures the scope before taking focus and keeps it for that opening; the capture tracks focus moves, so after focus falls to the body the last panel's offer stands for scope-matched actions, while a registration fenced by within answers only while the capture is inside its element. When several panels share a scope name and register the same action ID, supply within — useHotkeyScope() from inside the scope — so the panel containing the captured focus answers; the scope element counts as inside, which matters because HotkeyScope takes focus on click. Grammar rows are not scope-filtered.
Matching is case-insensitive, and every query word must match. Each word scores highest for an exact title, followed by a title prefix, title word start, other title substring, metadata match, or letters in order within the title. Metadata includes the subtitle, group, ID, and keywords. scorePaletteAction(action, query) returns the combined score, -1 for a miss, or 0 for an empty query. Registered actions are ranked, then collected under their group headings.
createActionRegistry() accepts an optional { maxRecents?: number }; maxRecents defaults to 8, Infinity keeps every recent, and a value that isn't a number at or above zero reads as the default. Register panel actions on mount and use the returned function to remove them on unmount.
| Method | Input → output | Behavior |
|---|---|---|
register |
PaletteAction | readonly PaletteAction[] → () => void |
Adds actions; same-ID registrations stack. Cleanup removes one copy of each action this call added and nothing else. |
list |
No input → readonly PaletteAction[] |
Returns the action snapshot. Its reference stays stable between registry changes. |
recents |
No input → readonly PaletteRecent[] |
Returns the recent snapshot, newest first; stable until recents change. |
touch |
PaletteRecent → void |
Moves an entry to the front, deduplicates it, caps the list, and notifies both kinds of listener. |
loadRecents |
readonly PaletteRecent[] → void |
Replaces recents, caps the list, and notifies subscribers without calling onRecentsChange listeners. It skips an entry it can't read, such as an action without a text id or a symbol whose symbol isn't text, so stored data you didn't validate can't break the list. |
onRecentsChange |
(recents: readonly PaletteRecent[]) => void → () => void |
Subscribes to touch updates; returns cleanup. |
subscribe |
() => void → () => void |
Subscribes to action and recent changes; returns cleanup. |
Previous call shapes and the registry changes are mapped in Migrating to v2. Palettes sharing a registry share its actions and recents. Each filters its own rows by query and captured scope. The opening shortcut contends per hotkey registry: the first same-variant palette declares the binding, the last-mounted one answers while mounted, unmounting it hands the shortcut back, and unmounting the declaring palette retires the binding even while another is mounted — one palette per variant per hotkey registry is the supported shape.
Symbols
SymbolSearchAdapter supplies the search; the component handles timing and cancellation.
| Field | Type | Default | Purpose |
|---|---|---|---|
search |
(query: string, signal: AbortSignal) => Promise<readonly SymbolResult[]> |
Required | Searches with the trimmed input and an abort signal. |
minLength |
number |
1 |
Minimum query length before searching. A value that isn't a finite number at or above zero reads as the default. |
debounceMs |
number |
150 |
Delay in milliseconds before calling search. A value that isn't a finite number at or above zero reads as the default, and one past 2,147,483,647 as that. |
Query or adapter changes, closure and unmount cancel the pending debounce and abort the previous request. Reopening an inline list starts a fresh lookup. Answers from aborted requests are ignored even if the adapter resolves them, and results for another query are hidden. loading is true until the current adapter answers the qualifying query. A rejection or synchronous search error produces no symbol results. Supply searching and empty text through CommandPaletteEmpty.
SymbolResult field |
Type | Required | Display |
|---|---|---|---|
symbol |
string |
Yes | Row title. |
name |
string |
No | Subtitle. |
exchange |
string |
No | Appended to the subtitle; part of the symbol's identity. |
kind |
string |
No | Asset-class or instrument-type badge. |
Recents
An empty query supplies eligible recents before the registered actions. Callers choose how many to render; the examples use group.rows.slice(0, 5) for the recent group. Missing or out-of-scope actions are skipped. Registered actions still appear in their usual groups too.
PaletteRecent is { kind: "action"; id: string; scope?: string } or { kind: "symbol"; symbol: SymbolResult }. touch deduplicates actions by ID and scope, and symbols by symbol plus exchange. Touching a scoped action entry also removes any scope-less entry with the same ID while no unscoped registration is live for it, which retires what earlier versions persisted; a live global action keeps its entry. Grammar rows do not enter recents.
Selecting a row clears the input and requests closure, then updates recents when applicable, then invokes the action callback. This applies to primary and secondary actions. Persistence is yours: save through onRecentsChange and restore with loadRecents.
Hotkeys
| Variant | Binding ID | Default keys | Scope | Effect |
|---|---|---|---|---|
palette |
palette.open |
mod+k |
editing |
Toggles the dialog, including while typing. |
go-bar |
go-bar.focus |
/ |
global |
Focuses the inline input outside typing contexts. |
HotkeysProvider supplies and attaches the registry. Pass hotkeys={registry} to use one directly; attach its dispatcher yourself. See useHotkeys for registry setup and scope rules.
If the binding ID already exists — declared on the registry before render, or in the surrounding provider's bindings, which the component sees during render — its keys and wording remain yours; the component only attaches its handler. A declaration that replaces the component's default after mount is also yours to keep, unless it matches the default in every field: the component cannot tell that apart from its own registration, so unmount removes it, along with any handler it carried. Otherwise it declares the binding and removes it on unmount. hotkey="mod+p" changes the default keys. hotkey={false} disables the opening or focus binding but keeps row shortcuts; hotkeys={null} disables both.
Row shortcuts follow the current keys for bindingId, including remaps. The dispatcher stops at dialogs. A single-step opening shortcut also closes the palette from inside, ahead of the keys the palette reads, so a shortcut on Shift+Enter closes it rather than running a row's second action; use Escape for a multi-step binding.
Dialog focus can arrive after opening. For up to one second, or until focus reaches the palette, it captures keys outside itself: text without Ctrl, Meta, or Alt enters the query; Enter runs the highlighted row under any modifiers, with Shift picking the secondary action; and Escape requests closure under any modifiers. Every other key outside the palette stops there as well: the palette ignores it, bubbling listeners and the hotkey dispatcher never see it, though capture-phase listeners ahead of the palette's still run, as does the browser's own default, such as Tab moving focus. Those events do not reach the hotkey dispatcher. Composition events are left alone.
In a popout window whose document the registry listens on, as PanelPopout and Workspace attach it, the shortcut opens the dialog in the window that renders the palette, and the panel the key came from answers its scoped actions. The keyboard stays in the popout, so from the key after the shortcut until the palette closes, every key in that window is the palette's, with or without a modifier, and none reaches the popout's bindings: text enters the query, Option and AltGr characters and a paste included, Backspace deletes, Up, Down, Home, and End move through the rows, a single-step opening shortcut closes it, and otherwise Enter runs the highlighted row and Escape closes under any modifiers. Other keys stop at the palette; a shortcut with Ctrl or Meta does nothing to a field in the popout except copy, and outside a field the browser keeps its own. A press in the popout closes the palette too. Keys an input method holds pass to the popout as they would with the palette closed, so text typed through one lands in its focused field, not the query. When you render the palette's parts in the popout itself, a key, a press, or a paste on the palette is its own. From a popout, / focuses the go-bar in the window that renders it.
Render the palette on the client: its key caps come from the registry's platform and remaps, which a server doesn't have.
Go-bar
Both variants use the same action, search, and recent APIs.
| Behavior | palette |
go-bar |
|---|---|---|
| Layout | Dialog | Always-visible input with a dropdown |
open controls |
Dialog visibility | Dropdown visibility |
| Focus | Dialog handles focus | Input focus requests opening; focus leaving the root requests closure |
| Selection | Clears the query and requests closure | Also blurs the input |
| Escape | Requests closure | Clears the query, requests closure, and blurs the input |
goBarGrammar receives trimmed, nonempty input and returns command rows before registered actions and symbol results. It runs synchronously in either variant. The first row is initially selected; Enter runs the highlighted row. See Inline commands for a complete grammar. AAPL offers every function in that example, AAPL G narrows to AAPL GP, and Enter runs it without waiting for symbol search.
The rest
labels accepts any of these string overrides. The title names the command interface in both variants; the dialog also receives the description. The underlying command supplies keyboard navigation and combobox/listbox semantics.
| Label | Dialog default | Go-bar default |
|---|---|---|
title |
Command palette |
Command line |
description |
Search for a command or a symbol |
Same |
placeholder |
Type a command or a symbol… |
Symbol, function, or command |
recent |
Recent |
Same |
actions |
Actions |
Same |
commands |
Commands |
Same |
symbols |
Symbols |
Same |
hotkey |
Open the command palette |
Focus the command line |
cmdk arrives with your shadcn command component. The palette handles its own filtering and ordering to combine registered actions, grammar rows, and asynchronous symbols.
What it does not do
Nested pages, symbol lookup, symbol meaning and recent persistence belong to your application. Add surrounding content or a preview pane in your own layout. The component does not virtualize results.
Built on shadcn's command and kbd.
This page is docs/command-palette.md at v2.0.0.