CommandPalette
Search registered actions and symbols in a dialog or inline command line, with shared recents and live shortcuts from the hotkey registry. Built on your shadcn command component.
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, 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} />
</div>
</HotkeysProvider>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/command-palette#v1.4.13
npx shadcn@latest add tradecn/ui/command-palette#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/command-palette#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/command-palette#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge command kbdnpx shadcn@latest add badge command kbdyarn dlx shadcn@latest add badge command kbdbunx --bun shadcn@latest add badge command kbd -
Copy the files into your project:
components/ui/ command-palette.tsx import { cn } from "cn" import { useEffect, useRef, useState, useSyncExternalStore, type FocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent } from "react" import { Badge } from "@/components/ui/badge" import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandShortcut } from "@/components/ui/command" import { Kbd, KbdGroup } from "@/components/ui/kbd" import { useMaybeHotkeys } from "@/hooks/use-hotkeys" import { formatKeys, matchesKeys, scopeChain, type HotkeyEntry, type HotkeyRegistry, type Platform } from "@/lib/hotkeys" // A command palette on the consumer's own shadcn `command`: actions from a registry, symbols from // an adapter, and a second action per row on Shift+Enter (open, or open and add to the watchlist). // // Two variants share the registry, the rows, and the recents. `palette` is the dialog on mod+k. // `go-bar` is the same thing inline, for terminals where the command line is always on screen, and // it reads `<SYMBOL> <FUNCTION>` through a grammar the consumer supplies. // // The palette filters and orders its own rows rather than leaving it to cmdk, because three sources // land in one list and one of them is asynchronous. Shortcuts on the rows come from the hotkey // registry, so a remap shows up here without anyone telling the palette. export interface PaletteAction { id: string title: string /** Muted text after the title. */ 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 keywords?: readonly string[] /** The heading the row sits under. */ group?: string /** A hotkey binding whose keys render on the row. */ bindingId?: string run: () => void /** Runs on Shift+Enter. The hint shows on the highlighted row, so it is discoverable. */ secondary?: { title: string; run: () => void } } export interface SymbolResult { symbol: string name?: string exchange?: string /** Asset class or instrument type, rendered as a 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 } | { kind: "symbol"; symbol: SymbolResult } export interface ActionRegistry { /** Returns the unregister. An action with an id already present replaces it. */ 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:${r.id}` : `symbol:${r.symbol.symbol}:${r.symbol.exchange ?? ""}`) export function createActionRegistry(options: { maxRecents?: number } = {}): ActionRegistry { const maxRecents = 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, action) emit() return () => { // Only what this call registered: a later registration under the same id is someone else's. let changed = false for (const action of added) if (actions.get(action.id) === action) changed = actions.delete(action.id) || changed if (changed) emit() } }, list: () => (snapshot ??= [...actions.values()]), recents: () => recents, touch(recent) { const key = recentKey(recent) recents = [recent, ...recents.filter((r) => recentKey(r) !== key)].slice(0, maxRecents) for (const cb of listeners) cb() for (const cb of recentListeners) cb(recents) }, loadRecents(next) { recents = next.slice(0, maxRecents) 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 empty: string searching: 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…", empty: "No results", searching: "Searching…", 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 /** `palette` is a dialog. `go-bar` renders inline and drops its rows below the input. */ 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> /** On the dialog's content for `palette`, on the root for `go-bar`. */ className?: string } interface Row { key: string title: string subtitle?: string badge?: string keys?: string run: () => void secondary?: { title: string; run: () => void } recent: PaletteRecent | null } interface Section { heading: string rows: Row[] } 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 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 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 focusScopes = focused?.closest(`[data-slot="${SLOT}"]`) ? AMBIENT_SCOPES : scopeChain(focused).join(" ") document.addEventListener("focusin", onFocusIn) } focusListeners.add(cb) return () => { focusListeners.delete(cb) if (!focusListeners.size) document.removeEventListener("focusin", onFocusIn) } } const getFocusScopes = () => focusScopes const getServerFocusScopes = () => AMBIENT_SCOPES const subscribeNothing = () => () => {} const getNoEntries = () => NO_ENTRIES function useSymbolSearch(adapter: SymbolSearchAdapter | undefined, query: string) { const [found, setFound] = useState<{ query: string; results: readonly SymbolResult[] }>({ query: "", results: NO_SYMBOLS }) const active = adapter !== undefined && query.length >= (adapter.minLength ?? 1) useEffect(() => { if (!adapter || !active) return const controller = new AbortController() const settle = (results: readonly SymbolResult[]) => { if (!controller.signal.aborted) setFound({ query, results }) } const timer = setTimeout(() => { adapter.search(query, controller.signal).then(settle, () => settle(NO_SYMBOLS)) }, adapter.debounceMs ?? 150) 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.query === query return { results: current ? found.results : NO_SYMBOLS, loading: active && !current } } function Keys({ keys, platform }: { keys: string; platform?: Platform }) { return ( <span className="inline-flex items-center gap-1"> {formatKeys(keys, platform).map((caps, i) => ( <KbdGroup key={i}> {caps.map((cap) => ( <Kbd key={cap}>{cap}</Kbd> ))} </KbdGroup> ))} </span> ) } interface BodyProps extends Pick<CommandPaletteProps, "actions" | "symbols" | "onSymbolSelect" | "symbolSecondary" | "goBarGrammar"> { variant: "palette" | "go-bar" labels: CommandPaletteLabels hotkeys: HotkeyRegistry | null /** The palette's own binding, to answer it from behind the dialog's wall. */ ownBindingId: string | null scopes: string expanded: boolean onExpand: () => void onDone: () => void } function PaletteBody({ actions, symbols, onSymbolSelect, symbolSecondary, goBarGrammar, variant, labels, hotkeys, ownBindingId, scopes, expanded, onExpand, onDone }: BodyProps) { 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) // 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, Enter runs the highlighted row, Escape closes, and nothing reaches the dispatcher. const early = useRef<{ enter: (shift: boolean) => void; escape: () => void }>({ enter: () => {}, escape: () => {} }) useEffect(() => { if (variant !== "palette") return const inside = (node: EventTarget | null) => Boolean((node as Element | null)?.closest?.(`[data-slot="${SLOT}"]`)) 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() if (event.isComposing) return event.stopPropagation() 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]) const active = new Set(scopes.split(" ")) const keysOf = new Map(entries.map((e) => [e.id, e.keys])) const scopeLabel = (scope: string) => scope.replace(/^panel:/, "") const actionRow = (action: PaletteAction, prefix: string, recent: PaletteRecent | null): Row => ({ key: `${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): Row => ({ key: `${prefix}:${symbol.symbol}:${symbol.exchange ?? ""}`, 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 }, }) const offered = list.filter((a) => !a.scope || active.has(a.scope)) const sections: Section[] = [] if (!query) { const rows: Row[] = [] for (const recent of recents) { if (recent.kind === "symbol") rows.push(symbolRow(recent.symbol, "recent-symbol")) else { const action = offered.find((a) => a.id === recent.id) if (action) rows.push(actionRow(action, "recent", recent)) } } if (rows.length) sections.push({ heading: labels.recent, rows: rows.slice(0, 5) }) for (const action of offered) pushRow(sections, action.group ?? labels.actions, actionRow(action, "action", { kind: "action", id: action.id })) } else { const commands = goBarGrammar?.(query) ?? [] if (commands.length) sections.push({ 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 })) if (search.results.length) sections.push({ 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 finish = (row: Row, run: () => void) => { setInput("") onDone() if (row.recent) actions.touch(row.recent) run() } useEffect(() => { early.current = { enter(shift) { const selected = document.querySelector(`[data-slot="${SLOT}"][data-variant="palette"] [cmdk-item][aria-selected="true"]`) const row = rowsByKey.get(selected?.getAttribute("data-row") ?? "") if (row) finish(row, shift && row.secondary ? row.secondary.run : row.run) }, escape: onDone, } }) const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { if (event.nativeEvent.isComposing) return if (event.key === "Enter" && event.shiftKey) { const selected = event.currentTarget.querySelector('[cmdk-item][aria-selected="true"]') const row = rowsByKey.get(selected?.getAttribute("data-row") ?? "") if (!row?.secondary) return event.preventDefault() finish(row, row.secondary.run) } else if (variant === "go-bar" && event.key === "Escape") { event.preventDefault() setInput("") onDone() } else if (variant === "palette" && ownBindingId) { // The dispatcher stops at the dialog, so the key that opened the palette closes it from here. const own = keysOf.get(ownBindingId) if (!own || !matchesKeys(event.nativeEvent, own, hotkeys?.platform)) return event.preventDefault() onDone() } } const hint = (row: Row) => row.secondary && ( <span data-secondary className="hidden items-center gap-1 in-data-[selected=true]:inline-flex" onClick={(event: MouseEvent) => { event.stopPropagation() finish(row, row.secondary!.run) }} > <Keys keys="shift+enter" platform={hotkeys?.platform} /> {row.secondary.title} </span> ) const rows = ( <> <CommandEmpty>{search.loading ? labels.searching : labels.empty}</CommandEmpty> {sections.map((section) => ( <CommandGroup key={section.heading} heading={section.heading}> {section.rows.map((row) => ( <CommandItem key={row.key} value={row.key} data-row={row.key} onSelect={() => finish(row, row.run)}> <span className="truncate">{row.title}</span> {row.subtitle && <span className="truncate text-muted-foreground">{row.subtitle}</span>} {row.badge && ( <Badge variant="outline" className="h-4 px-1 text-xs uppercase"> {row.badge} </Badge> )} {(row.secondary || row.keys) && ( <CommandShortcut className="flex shrink-0 items-center gap-2 tracking-normal"> {hint(row)} {row.keys && <Keys keys={row.keys} platform={hotkeys?.platform} />} </CommandShortcut> )} </CommandItem> ))} </CommandGroup> ))} </> ) if (variant === "palette") { return ( <Command shouldFilter={false} loop label={labels.title} onKeyDown={onKeyDown}> <CommandInput value={input} onValueChange={setInput} placeholder={labels.placeholder} /> <CommandList>{rows}</CommandList> </Command> ) } return ( <Command shouldFilter={false} loop label={labels.title} onKeyDown={onKeyDown} className="h-auto overflow-visible bg-transparent p-0"> <CommandInput value={input} onValueChange={setInput} placeholder={labels.placeholder} onFocus={onExpand} /> {expanded && ( // The rows are not focusable; without this a click blurs the input and the list is gone before the click lands. <CommandList onMouseDown={(event: MouseEvent) => event.preventDefault()} className="absolute top-full right-0 left-0 z-50 mt-1 rounded-md border border-border bg-popover text-popover-foreground shadow-md"> {rows} </CommandList> )} </Command> ) } function pushRow(sections: Section[], heading: string, row: Row) { const section = sections.find((s) => s.heading === heading) if (section) section.rows.push(row) else sections.push({ heading, rows: [row] }) } export function CommandPalette({ actions, variant = "palette", open: openProp, defaultOpen = false, onOpenChange, symbols, onSymbolSelect, symbolSecondary, goBarGrammar, hotkeys: hotkeysProp, hotkey, labels: labelsProp, className }: CommandPaletteProps) { 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 root = useRef<HTMLDivElement>(null) // Freeze where focus was at the moment of opening: a render later the palette itself has it. const liveScopes = useSyncExternalStore(subscribeFocusScopes, getFocusScopes, getServerFocusScopes) const [wasOpen, setWasOpen] = useState(open) const [scopes, setScopes] = useState(liveScopes) if (open !== wasOpen) { setWasOpen(open) if (open) setScopes(liveScopes) } 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(() => {}) useEffect(() => { onHotkey.current = () => { if (variant === "palette") setOpen(!open) else root.current?.querySelector("input")?.focus() } }) useEffect(() => { if (!hotkeys || keys === null) return // A consumer that declared this id already owns its keys and its wording. const declared = hotkeys.list().some((entry) => entry.id === bindingId) if (!declared) hotkeys.register({ id: bindingId, keys, scope: variant === "palette" ? "editing" : "global", description, group }) const unbind = hotkeys.bind(bindingId, () => onHotkey.current()) return () => { unbind() if (!declared) hotkeys.unregister(bindingId) } }, [hotkeys, keys, bindingId, variant, description, group]) const body = (expanded: boolean) => ( <PaletteBody actions={actions} symbols={symbols} onSymbolSelect={onSymbolSelect} symbolSecondary={symbolSecondary} goBarGrammar={goBarGrammar} variant={variant} labels={labels} hotkeys={hotkeys} ownBindingId={keys === null ? null : bindingId} scopes={scopes} expanded={expanded} onExpand={() => setOpen(true)} onDone={() => { setOpen(false) if (variant === "go-bar") root.current?.querySelector("input")?.blur() }} /> ) if (variant === "palette") { return ( <CommandDialog open={open} onOpenChange={setOpen} title={labels.title} description={labels.description} className={className}> <div data-slot="tradecn-command-palette" data-variant="palette" className="lining-nums tabular-nums"> {body(true)} </div> </CommandDialog> ) } return ( <div ref={root} data-slot="tradecn-command-palette" data-variant="go-bar" className={cn("relative w-full lining-nums tabular-nums", className)} onBlur={(event: FocusEvent<HTMLDivElement>) => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) }} > {body(open)} </div> ) }hooks/use-hotkeys.tsx import { createContext, useContext, useEffect, 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) 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 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}>{children}</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) } 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) useEffect(() => { latest.current = handler }) useEffect(() => { 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) } 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: only scopes declared inside it are active, so a // single-key binding cannot fire under a modal confirmation. 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 one, else the binding's own. */ keys: string defaultKeys: string 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 /** 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: "" } for (const token of part.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 (!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() const chordTimeoutMs = options.chordTimeoutMs ?? 1000 const recs = new Map<string, Rec>() 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 defaults = parseKeys(binding.keys, platform) let steps = defaults 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 = defaults } } 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 { if (event.defaultPrevented || event.isComposing || 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. if (previous && !handler && sameBinding(previous.binding, binding)) return conflictsFor(binding.id) recs.set(binding.id, build(binding, handler ?? previous?.handler)) clearPending() emit() return conflictsFor(binding.id) }, unregister(id) { if (!recs.delete(id)) return 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) return { ...rec.binding, keys, defaultKeys, remapped: keys !== defaultKeys } })) }, 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 { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, 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} />
</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.
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.
import { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, 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 [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} />
<p role="status">Command: {command}</p>
</div>
</HotkeysProvider>
)
}
Symbol search
Supply a SymbolSearchAdapter for asynchronous lookup. 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 { useState } from "react"
import { HotkeysProvider } from "@/hooks/use-hotkeys"
import { CommandPalette, 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…", empty: "No matching symbols" }}
/>
</div>
</HotkeysProvider>
)
}
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 { useCallback, useEffect, useState } from "react"
import { HotkeyScope, HotkeysProvider, useHotkey } from "@/hooks/use-hotkeys"
import type { HotkeyBinding } from "@/lib/hotkeys"
import { CommandPalette, 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), [])
useHotkey("book.refresh", refresh)
useEffect(() => actions.register({
id: "book.refresh",
title: "Refresh book",
scope: "panel:book",
bindingId: "book.refresh",
run: refresh,
secondary: { title: "Reset", run: () => setRequests(0) },
}), [actions, refresh])
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} />
</div>
</HotkeysProvider>
)
}
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
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
actions |
ActionRegistry |
Required | Registry created by createActionRegistry(). |
variant |
"palette" | "go-bar" |
"palette" |
Dialog or inline input with a dropdown. |
open |
boolean |
Uncontrolled | Controls the dialog or go-bar dropdown. |
defaultOpen |
boolean |
false |
Initial open state when uncontrolled. |
onOpenChange |
(open: boolean) => void |
— | Receives requested open-state changes in either mode. |
symbols |
SymbolSearchAdapter |
— | Supplies asynchronous symbol results. |
onSymbolSelect |
(symbol: SymbolResult) => void |
— | Runs for a symbol's primary selection. |
symbolSecondary |
{ title: string; run: (symbol: SymbolResult) => void } |
— | Alternate symbol action; does not call onSymbolSelect. |
goBarGrammar |
(input: string) => readonly PaletteAction[] |
— | Synchronous command rows for nonempty input, in either variant. |
hotkeys |
HotkeyRegistry | null |
Nearest HotkeysProvider, or null |
Supplies bindings and row shortcuts; null opts out. |
hotkey |
string | false |
"mod+k" or "/" by variant |
Default opening or focus binding; false disables its registration and handler. |
labels |
Partial<CommandPaletteLabels> |
See The rest | Overrides accessible names, messages, headings, and binding text. |
className |
string |
— | Styles the dialog content or go-bar root. |
When open is supplied, update it in onOpenChange to accept requests to open or close. Without it, the component manages that state.
Actions
PaletteAction describes a registered action or a row returned by goBarGrammar.
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
string |
Required | Identity; registering the same ID replaces the action. |
title |
string |
Required | Row title and primary search text. |
run |
() => void |
Required | Primary action. |
subtitle |
string |
— | Muted text after the title; also searchable. |
scope |
string |
Everywhere | Hotkey scope required to offer a registered action. Renders as a badge, with panel: removed. |
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 on the row. |
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. The secondary hint appears on the highlighted row and can be clicked. 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. 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. 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 or replaces actions by ID. Cleanup removes only the objects this call registered, leaving later replacements intact. |
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. |
onRecentsChange |
(recents: readonly PaletteRecent[]) => void → () => void |
Subscribes to touch updates; returns cleanup. |
subscribe |
() => void → () => void |
Subscribes to action and recent changes; returns cleanup. |
Palettes sharing a registry share its actions and recents. Each filters its own rows by query and captured scope.
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. |
debounceMs |
number |
150 |
Delay in milliseconds before calling search. |
Query changes cancel the pending debounce and abort the previous request. Answers from aborted requests are ignored even if the adapter resolves them, and results for another query are hidden. When a qualifying query has no current answer, the empty list shows labels.searching. A rejected promise produces no symbol results.
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 shows up to five recents before the registered actions. Missing or out-of-scope actions are skipped. Registered actions still appear in their usual groups too.
PaletteRecent is { kind: "action"; id: string } or { kind: "symbol"; symbol: SymbolResult }. touch deduplicates actions by ID and symbols by symbol plus exchange. 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, its keys and wording remain yours; the component only attaches its handler. 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; 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 and Shift+Enter run the highlighted row, and Escape requests closure. Those events do not reach the hotkey dispatcher. Composition events are left alone.
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 |
empty |
No results |
Same |
searching |
Searching… |
Same |
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
No nested pages or preview pane. Symbol lookup, symbol meaning, and recent persistence belong to your application.
Built on shadcn's badge, command, and kbd.
This page is docs/command-palette.md at v1.4.13.