Skip to content

CommandPalette

A centred floating dialog with a search input and a list of commands. Use it as the top-level “do anything” surface in an editor or app — open it with a global hotkey, type to filter, and press Enter to run. The component handles fuzzy matching, keyboard navigation, grouping, and recent-item tracking; the consumer wires up the hotkey and the command handlers.

Live Preview

When to use

  • CommandPalette — searchable surface for any action in the app. Lives outside the chrome and is summoned by a hotkey.
  • Menu — short, fixed list of actions anchored to a trigger. No search.
  • ContextMenu — actions scoped to a right-clicked target.

Reach for CommandPalette once your command set grows past what fits in a menu, or when keyboard-first users need to find things by name.

Import

import { CommandPalette } from 'entangle-ui';
import type { CommandItem } from 'entangle-ui';

The fuzzy-matching helpers are exported too — useful if you want to power a custom UI with the same scoring:

import { fuzzyFilter, fuzzyScore } from 'entangle-ui';

Usage

CommandPalette is fully controlled. Hold the open state in the parent and bind a hotkey to toggle it:

const [open, setOpen] = useState(false);
useHotkey('Mod+K', () => {
setOpen(true);
});
<CommandPalette
open={open}
onClose={() => setOpen(false)}
items={[
{ id: 'open', label: 'Open File', shortcut: 'Cmd+O', group: 'File' },
{ id: 'save', label: 'Save', shortcut: 'Cmd+S', group: 'File' },
]}
onSelect={item => runCommand(item.id)}
recentKey="myapp:command-palette"
/>;

The component does not bind a global hotkey itself — that belongs in the consumer so the binding can be configured, disabled in modals, or swapped to match the host application’s conventions.

Binding a global hotkey

Wire the open state to useHotkey('Mod+K', ...) (or your hotkey library of choice). The example below uses a raw keydown listener for portability.

With a global hotkey

useEffect(() => {
function handler(e: KeyboardEvent) {
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k';
if (isCmdK) {
e.preventDefault();
setOpen(o => !o);
}
}
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);

Fuzzy filtering

Typing in the input fuzzy-matches against each item’s label, description, and keywords. The matcher scores subsequence hits and rewards matches that land on word boundaries — so “of” matches “Open File” higher than “Preof”. The filter is debounced (150 ms by default) and re-sorts the list by score on every keystroke.

<CommandPalette
items={[
{
id: 'theme',
label: 'Switch Theme',
group: 'Preferences',
keywords: ['dark', 'light', 'appearance'],
},
]}
/>

Tune the responsiveness with the debounceMs prop. Set it to 0 for snappy lists, raise it for very large item sets where filtering is expensive in the consumer’s onSelect chain.

Groups

Each item’s group field becomes a section header. Items without a group go into an unlabelled default section that renders first.

Grouped commands

<CommandPalette
items={[
{ id: 'open', label: 'Open File', group: 'File' },
{ id: 'save', label: 'Save', group: 'File' },
{ id: 'find', label: 'Find in Files', group: 'Search' },
]}
/>

Recent items

Pass a recentKey to track the last few selections in localStorage. When the input is empty, recents render in their own group at the top of the list. The component falls back gracefully when storage is unavailable (private browsing, SSR).

<CommandPalette
recentKey="myapp:command-palette"
maxRecent={5}
recentLabel="Recent"
/>

Omit recentKey to disable tracking entirely.

Keyboard shortcuts on items

Pass a shortcut string to render a trailing <Kbd> chip. The shortcut is for display only — it doesn’t bind a global key, so the consumer is still responsible for wiring it up at the application level.

const items = [
{ id: 'save', label: 'Save', shortcut: 'Cmd+S' },
{ id: 'find', label: 'Find in Files', shortcut: 'Shift+Cmd+F' },
];

Empty state

Provide a custom emptyState node, or let the default “No matches for …” message render.

Empty state

<CommandPalette items={[]} emptyState={<span>Nothing to do here yet.</span>} />

Custom item renderer

For richer rows — multi-line descriptions, badges, inline previews — pass renderItem to replace the default layout. The renderer receives the item and a { selected, query } state object.

Custom row renderer

<CommandPalette
items={items}
renderItem={(item, state) => (
<div
style={{
display: 'flex',
gap: 12,
fontWeight: state.selected ? 600 : 400,
}}
>
<span style={{ flex: 1 }}>{item.label}</span>
{item.shortcut && <span>{item.shortcut}</span>}
</div>
)}
/>

Item handlers

Each item can supply its own onSelect callback for a one-shot action. When omitted, the top-level onSelect on CommandPalette is called with the item.

<CommandPalette
items={[
{ id: 'open', label: 'Open File', onSelect: () => openFileDialog() },
{ id: 'save', label: 'Save' },
]}
onSelect={item => runCommand(item.id)}
/>

Use disabled: true on an item to render it but skip it during keyboard navigation and click.

Accessibility

  • The dialog uses role="dialog" with aria-modal="true" and aria-label="Command palette".
  • The input is a role="combobox" with aria-controls pointing at the listbox and aria-activedescendant reflecting the highlighted item.
  • The list is a role="listbox" of role="option" items; the active option has aria-selected="true".
  • Keyboard: ArrowUp / ArrowDown to navigate, Home / End to jump, Enter to run, Escape to close. Hover mirrors keyboard selection.
  • Disabled items receive aria-disabled="true" and are skipped by keyboard navigation.
  • Focus moves to the input on open. The consumer is responsible for restoring focus to the trigger on close.

API Reference

<CommandPalette>

Prop Type Default Description
open boolean Whether the palette is open.
onClose () => void Called when the palette requests close (Escape, overlay click, after selection).
items readonly CommandItem[] All commands. The component filters them as the user types.
onSelect (item: CommandItem) => void Default handler invoked when an item without its own `onSelect` is chosen.
placeholder string 'Type a command or search...' Placeholder text for the search input.
emptyState ReactNode Custom node rendered when no items match the current query.
recentKey string `localStorage` key for tracking recently selected items. Recent tracking is off when omitted.
recentLabel string 'Recent' Localised label for the recent items group header.
maxRecent number 5 Maximum number of recent items to track.
debounceMs number 150 Debounce applied to the search query, in ms.
renderItem (item: CommandItem, state: { selected: boolean; query: string }) => ReactNode Custom renderer for each item — replaces the default row layout.
portal boolean true Render in a portal on `document.body`.
maxHeight number 400 Maximum height of the results list, in px.
width number | string 640 Width of the panel. Number → px, string → CSS value.

CommandItem

Prop Type Default Description
id string Stable unique identifier — used for keys and the recent list.
label string Visible label.
description string Optional secondary description rendered below the label.
group string Group name for sectioning the list. Items without a group fall into the default section.
icon ReactNode Leading icon rendered before the label.
shortcut string Keyboard shortcut to display (e.g. "Cmd+S"). Rendered as a `<Kbd>` chip.
keywords string[] Extra strings used by the fuzzy matcher in addition to label and description.
disabled boolean When true, the item is rendered but cannot be selected and is skipped by keyboard navigation.
onSelect () => void Callback fired when this item is chosen. Falls back to the top-level `onSelect`.

Helpers

fuzzyFilter(query, items, getStrings) and fuzzyScore(query, target) are exported for building custom search UIs that match the palette’s behaviour. fuzzyFilter returns { item, score }[] sorted by descending score, with non-matches dropped.