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

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>
      ))}
    </>
  )
}

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.

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 CommandPaletteSharedRecentsDemo() {
  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="min-h-80 w-md max-w-full space-y-3 text-sm">
        <CommandPalette variant="go-bar" actions={actions}>
          <CommandPaletteContent>
            <CommandPaletteInput />
            <CommandPaletteList><PaletteResults /></CommandPaletteList>
          </CommandPaletteContent>
        </CommandPalette>
        <button type="button" className="rounded border border-border px-3 py-2 hover:bg-muted" onClick={() => setOpen(true)}>Open dialog</button>
        <p role="status">Selected: {selected}</p>
        <CommandPalette actions={actions} open={open} onOpenChange={setOpen}>
          <CommandPaletteDialog>
            <CommandPaletteContent>
              <CommandPaletteInput />
              <CommandPaletteList><PaletteResults /></CommandPaletteList>
            </CommandPaletteContent>
          </CommandPaletteDialog>
        </CommandPalette>
      </div>
    </HotkeysProvider>
  )
}

function PaletteResults() {
  return (
    <>
      <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}>
                <span className="truncate">{row.title}</span>
              </CommandPaletteItem>
            ))}
          </CommandGroup>
        )}
      </CommandPaletteResults>
    </>
  )
}

API Reference

<CommandPalette>

actions
Type: ActionRegistry Required
Registry created by createActionRegistry().
children
Type: ReactNode Required
Caller-owned dialog or inline content.
variant
Type: "palette" | "go-bar" Default: "palette"
Selects keyboard and focus behavior; it does not insert a dialog.
open
Type: boolean Default: Uncontrolled
Controls the dialog or go-bar dropdown.
defaultOpen
Type: boolean Default: false
Initial open state when uncontrolled.
onOpenChange
Type: (open: boolean) => void Optional
Receives requested open-state changes in either mode.
symbols
Type: SymbolSearchAdapter Optional
Supplies asynchronous symbol results.
onSymbolSelect
Type: (symbol: SymbolResult) => void Optional
Runs for a symbol's primary selection.
symbolSecondary
Type: object Optional
Alternate symbol action; does not call onSymbolSelect.
Full type for symbolSecondary
{ title: string; run: (symbol: SymbolResult) => void }
goBarGrammar
Type: function Optional
Synchronous command rows for nonempty input, in either variant.
Full type for goBarGrammar
(input: string) => readonly PaletteAction[]
hotkeys
Type: HotkeyRegistry | null Default: Nearest HotkeysProvider, or null
Supplies bindings and row shortcuts; null opts out.
hotkey
Type: string | false Default: "mod+k" or "/" by variant
Default opening or focus binding; false disables its registration and handler.
labels
Type: Partial<CommandPaletteLabels> Default: See The rest
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.

id
Type: string
recent, commands, symbols, or actions: followed by the group heading.
heading
Type: string
Localized group heading or the action's own group.
rows
Type: readonly PaletteRow[]
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
Type: string
Result identity; distinguishes registered, grammar, symbol and recent rows.
title
Type: string
Action title or symbol. Render it or supply a meaningful accessible name.
subtitle
Type: string | undefined
Action subtitle, or symbol name and exchange joined with ·.
badge
Type: string | undefined
Scope with panel: removed, or symbol kind.
keys
Type: string | undefined
Current binding keys; pass to CommandPaletteKeys.
secondary
Type: object | undefined
Alternate callback and text.
Full type for secondary
{ title: string; run: () => void } | undefined
run
Type: () => void
Raw callback. Use Item or select to retain query, closure and recent behavior.
recent
Type: PaletteRecent | null
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
Type: readonly PaletteGroup[]
All eligible results. Callers choose group order, row markup and limits.
input, setInput
Type: string, (input: string) => void
Raw query and setter; matching uses trimmed input.
loading
Type: boolean
A qualifying symbol query has no current answer.
open, setOpen
Type: boolean, (open: boolean) => void
Root state and a request to change it.
platform
Type: Platform | undefined
The registry's hotkey platform: its configured value, else the detected one. undefined without a hotkey registry.
select
Type: function
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.