modern-cmdk (Core)
Framework-agnostic headless command palette engine. Pure TypeScript with no hard DOM dependency (DOM APIs are feature-detected and SSR-safe), portable to any runtime. All classes implement Disposable for using/await using cleanup.
Installation
pnpm add modern-cmdknpm install modern-cmdkimport { createCommandMachine, itemId } from 'modern-cmdk';State Machine
createCommandMachine(options?)
Creates a headless command palette state machine with search, frecency, and keyboard shortcut support. Implements Disposable for automatic cleanup via using.
import { createCommandMachine, itemId } from 'modern-cmdk';
// Clipboard helpers; the palette itself does not provide them.
function copy(): void {
void navigator.clipboard.writeText(window.getSelection()?.toString() ?? '');
}
function paste(): void {
void navigator.clipboard.readText().then((text) => console.log('pasted', text));
}
using machine = createCommandMachine({
items: [
{ id: itemId('copy'), value: 'Copy', shortcut: 'Mod+C', onSelect: () => copy() },
{ id: itemId('paste'), value: 'Paste', shortcut: 'Mod+V', onSelect: () => paste() },
],
frecency: { enabled: true },
loop: true,
});
machine.send({ type: 'SEARCH_CHANGE', query: 'cop' });
machine.subscribe(() => console.log(machine.getState()));
// Automatic cleanup when `machine` goes out of scopeCommandMachineOptions
| Option | Type | Default | Description |
|---|---|---|---|
items | CommandItem[] | [] | Pre-registered items to populate on creation |
groups | CommandGroup[] | [] | Pre-registered groups for item categorization |
filter | Function | false | built-in | Custom filter function, or false to disable filtering |
search | SearchEngine | built-in | Pluggable search engine (e.g., WASM engine) |
label | string | 'Command palette' | Accessible label for the palette |
open | boolean | false | Initial open state |
frecency | FrecencyOptions | undefined | Frecency ranking configuration |
loop | boolean | true | Whether keyboard navigation wraps around |
virtualizeThreshold | number | 100 | Accepted but not read yet; Command.List virtualizes above a fixed 100 matches (off again at 50 or fewer) |
onSelect | (id: ItemId) => void | -- | Callback when an item is selected |
onActiveChange | (id: ItemId | null) => void | -- | Callback when the active item changes |
onOpenChange | (open: boolean) => void | -- | Callback when open state changes |
onSearchChange | (query: string) => void | -- | Callback when the search query changes |
CommandMachine Interface
| Method | Returns | Description |
|---|---|---|
getState() | CommandState | Returns current immutable state snapshot |
send(event) | void | Dispatches an event (batched via requestAnimationFrame) |
subscribe(listener) | () => void | Subscribe to state changes (useSyncExternalStore-compatible) |
subscribeState(listener) | Disposable | Subscribe with full state — returns Disposable for using |
getRegistry() | CommandRegistry | Access the command registry |
getKeyboardRegistry() | KeyboardShortcutRegistry | Access the keyboard shortcut registry |
getFilteredIdSet() | ReadonlySet<ItemId> | Current filtered IDs — O(1) membership checks |
getFilteredIdIndex() | ReadonlyMap<ItemId, number> | Positional index — O(1) positional lookup |
[Symbol.dispose]() | void | Clean up all resources (search engine, frecency, listeners) |
CommandState
Immutable state snapshot. Each mutation produces a new object reference for efficient change detection.
| Field | Type | Description |
|---|---|---|
search | string | Current search query |
activeId | ItemId | null | Currently highlighted item, or null |
filteredIds | readonly ItemId[] | Ordered list of visible item IDs |
groupedIds | ReadonlyMap<GroupId, readonly ItemId[]> | Group ID → visible item IDs |
filteredCount | number | Count of visible items |
loading | boolean | true while any <Command.AsyncItems> is pending (the LOADING event) |
page | string | Current page identifier |
pageStack | readonly string[] | Previous pages for back-navigation |
open | boolean | Whether the palette is open |
lastUpdated | number | Epoch-millisecond timestamp of last mutation |
Events (CommandEvent)
| Event Type | Payload | Description |
|---|---|---|
SEARCH_CHANGE | { query: string } | Update search query |
ITEM_SELECT | { id: ItemId } | Select an item |
ITEM_ACTIVATE | { id: ItemId } | Highlight an item |
NAVIGATE | { direction: 'next' | 'prev' | 'first' | 'last' } | Navigate items |
PAGE_PUSH | { page: string } | Push a new page |
PAGE_POP | -- | Pop to previous page |
OPEN | -- | Open the palette |
CLOSE | -- | Close the palette |
TOGGLE | -- | Toggle open state |
ITEMS_LOADED | { items: readonly CommandItem[] } | Bulk-load async items |
LOADING | { source: string; pending: boolean } | A <Command.AsyncItems> source started (pending true) or finished (pending false); loading is true while any source is pending |
REGISTER_ITEM | { item: CommandItem } | Register a new item |
UNREGISTER_ITEM | { id: ItemId } | Remove an item |
REGISTER_GROUP | { group: CommandGroup } | Register a new group |
UNREGISTER_GROUP | { id: GroupId } | Remove a group |
createInitialState(options?)
Creates a default CommandState. Useful for testing or custom machine implementations.
import { createInitialState } from 'modern-cmdk';
const state = createInitialState({ open: true });
// { search: '', activeId: null, filteredIds: [], filteredCount: 0, ... }Types
CommandItem
| Field | Type | Required | Description |
|---|---|---|---|
id | ItemId | Yes | Unique identifier (branded type) |
value | string | Yes | Display text and primary search target |
keywords | readonly string[] | No | Additional searchable terms |
groupId | GroupId | No | Group this item belongs to |
shortcut | string | No | Keyboard shortcut (e.g., "Mod+K") |
disabled | boolean | No | Excluded from filtering and selection |
onSelect | () => void | No | Callback when selected. Called with no arguments, in the core and in <Command.Item onSelect> |
data | Record<string, unknown> | No | Arbitrary metadata |
CommandGroup
| Field | Type | Required | Description |
|---|---|---|---|
id | GroupId | Yes | Unique identifier (branded type) |
heading | string | No | Display heading text |
priority | number | No | Sort priority (lower = higher in list) |
Branded Types
Branded types prevent accidental mixing of plain strings with IDs at compile time.
import { itemId, groupId } from 'modern-cmdk';
const id = itemId('settings'); // type: ItemId (not string)
const gid = groupId('navigation'); // type: GroupId (not string)
// TypeScript catches this at compile time:
// machine.send({ type: 'ITEM_SELECT', id: 'plain-string' }); // Error!| Function | Returns | Description |
|---|---|---|
itemId(id) | ItemId | Create a branded item ID. Ensures well-formed Unicode. |
groupId(id) | GroupId | Create a branded group ID. Ensures well-formed Unicode. |
Search Engine
createSearchEngine(options?)
Creates a pluggable search engine with fuzzy scoring, incremental filtering, and Iterator Helpers pipeline.
import { createSearchEngine, itemId } from 'modern-cmdk';
import type { CommandItem } from 'modern-cmdk';
const items: CommandItem[] = [
{ id: itemId('settings'), value: 'Settings' },
{ id: itemId('profile'), value: 'Profile' },
];
using engine = createSearchEngine();
engine.index(items);
const results = engine.search('sett', items);
for (const result of results) {
console.log(result.id, result.score, result.matches);
}SearchEngine Interface
| Method | Returns | Description |
|---|---|---|
index(items) | void | Index items for fast lookup |
search(query, items) | IteratorObject<SearchResult> | Search items, returns lazy iterator of scored results |
remove(ids) | void | Remove items from index by ID set |
clear() | void | Clear the entire index |
[Symbol.dispose]() | void | Dispose resources |
SearchResult
| Field | Type | Description |
|---|---|---|
id | ItemId | Matched item ID |
score | number | Match quality (0–1, higher = better) |
matches | [number, number][] | Character ranges for highlighting |
ScorerFn
import type { CommandItem, SearchResult } from 'modern-cmdk';
type ScorerFn = (query: string, item: CommandItem) => SearchResult | null;Custom scoring function. Return null for no match, or a SearchResult with score and match ranges.
Scoring Utilities
| Function | Description |
|---|---|
scoreItem(query, item) | Default scorer — exact, prefix, substring, word boundary, fuzzy |
scoreItemAsync(query, item) | Async version with scheduler.yield() for large datasets |
batchScoreItems(query, items, batchSize?) | Batch scoring with yielding between batches |
Frecency
FrecencyEngine
Frequency x recency ranking with bucketed recency weights (hour, day, week, month, older). Items used recently and frequently get a bonus that is added to their match score for non-empty queries; an empty query keeps registry order.
import { FrecencyEngine, MemoryFrecencyStorage, itemId } from 'modern-cmdk';
using storage = new MemoryFrecencyStorage();
using frecency = new FrecencyEngine({ storage, namespace: 'my-app' });
frecency.recordSelection(itemId('settings'));
const bonus = frecency.getBonus(itemId('settings')); // e.g., 4.0FrecencyOptions
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean (optional) | false | Turns frecency ranking on for createCommandMachine({ frecency }) and <Command frecency>: ranking applies only with enabled: true. Flips to true in 2.0. new FrecencyEngine({ storage, namespace }) needs no enabled field |
storage | FrecencyStorage | MemoryFrecencyStorage | Persistence backend |
namespace | string | 'default' | Storage namespace for isolation |
decayConfig | FrecencyDecayConfig | See below | Decay curve weights |
FrecencyDecayConfig
| Field | Type | Default | Description |
|---|---|---|---|
hourWeight | number | 4.0 | Boost for items used in the last hour |
dayWeight | number | 2.0 | Boost for items used today |
weekWeight | number | 1.5 | Boost for items used this week |
monthWeight | number | 1.0 | Boost for items used this month |
olderWeight | number | 0.5 | Boost for older items |
Storage Implementations
| Class | Description |
|---|---|
MemoryFrecencyStorage | In-memory storage (lost on page reload). Implements Disposable. |
IdbFrecencyStorage | IndexedDB persistence via idb-keyval. Implements AsyncDisposable. |
import { IdbFrecencyStorage } from 'modern-cmdk';
// Persist frecency data across browser sessions
await using storage = new IdbFrecencyStorage('my-db', 'frecency-store');FrecencyStorage Interface
import type { FrecencyData } from 'modern-cmdk';
interface FrecencyStorage extends Disposable {
load(namespace: string): FrecencyData | Promise<FrecencyData>;
save(namespace: string, data: FrecencyData): void | Promise<void>;
}Keyboard Registry
KeyboardShortcutRegistry
Built-in keyboard shortcut management with cross-platform Mod key, and conflict detection.
import { KeyboardShortcutRegistry, itemId } from 'modern-cmdk';
// Clipboard helpers; the palette itself does not provide them.
function copy(): void {
void navigator.clipboard.writeText(window.getSelection()?.toString() ?? '');
}
function paste(): void {
void navigator.clipboard.readText().then((text) => console.log('pasted', text));
}
using registry = new KeyboardShortcutRegistry();
registry.register('Mod+C', itemId('copy'), () => copy());
registry.register('Mod+V', itemId('paste'), () => paste());
// Detect conflicts
const conflicts = registry.getConflicts();
// conflicts: ReadonlyMap<string, readonly ParsedShortcut[]> (normalized shortcut → conflicting bindings)| Method | Returns | Description |
|---|---|---|
register(shortcut, itemId, handler) | Disposable | Bind a shortcut — using auto-unregisters |
unregister(shortcut) | void | Remove a binding by shortcut string |
getConflicts() | ReadonlyMap<string, readonly ParsedShortcut[]> | Normalized shortcut → conflicting bindings |
[Symbol.dispose]() | void | Remove the global keydown listener and clear all bindings |
Shortcut Utilities
| Function | Returns | Description |
|---|---|---|
parseShortcut(shortcut) | ParsedShortcut | Parse "Mod+Shift+K" into structured object |
formatShortcut(parsed) | string | Format back to display string |
matchesShortcut(event, parsed) | boolean | Check if a KeyboardEvent matches a parsed shortcut |
findMatchingShortcut(event, shortcuts) | ParsedShortcut | null | Find first matching shortcut |
detectConflicts(shortcuts) | ReadonlyMap<string, readonly ParsedShortcut[]> | Detect duplicate shortcut bindings |
ParsedShortcut
interface ParsedShortcut {
readonly key: string; // Normalized key name
readonly meta: boolean;
readonly ctrl: boolean;
readonly shift: boolean;
readonly alt: boolean;
readonly raw: string; // Original shortcut string (well-formed)
readonly normalized: string; // Deterministic form for dedup/comparison
}Shortcut Syntax
| Token | Meaning | macOS | Windows/Linux |
|---|---|---|---|
Mod | Platform modifier | Cmd (⌘) | Ctrl |
Ctrl | Control key | Ctrl (⌃) | Ctrl |
Shift | Shift key | Shift (⇧) | Shift |
Alt | Alt/Option key | Option (⌥) | Alt |
+ | Separator | -- | -- |
Example: "Mod+Shift+K" → macOS: ⌘⇧K, Windows: Ctrl+Shift+K
Telemetry
createTelemetryMiddleware(hooks)
Creates opt-in telemetry middleware for enterprise observability. Zero overhead when hooks are not provided.
import { createTelemetryMiddleware } from 'modern-cmdk';
// Your analytics client (app-owned).
declare const analytics: { track(event: string, properties?: Record<string, unknown>): void };
const telemetry = createTelemetryMiddleware({
onPaletteOpen: () => analytics.track('palette_opened'),
onPaletteClose: (durationMs) => analytics.track('palette_closed', { durationMs }),
onSearchComplete: (query, count, durationMs) =>
analytics.track('search', { query, resultCount: count, durationMs }),
onItemSelected: (id, query, position) =>
analytics.track('item_selected', { id, query, position }),
});CommandTelemetryHooks
| Hook | Signature | Description |
|---|---|---|
onPaletteOpen | () => void | Called when the palette opens |
onPaletteClose | (durationMs: number) => void | Called when it closes, with session duration |
onSearchComplete | (query, resultCount, durationMs) => void | Called after each search |
onItemSelected | (itemId, searchQuery, position) => void | Called when an item is selected |
Utilities
CommandRegistry
Internal registry for items and groups. Access via machine.getRegistry().
| Method | Returns | Description |
|---|---|---|
registerItem(item) | Disposable | Register a command item — using auto-deregisters |
registerItems(items) | Disposable | Batch-register items with a single disposer |
unregisterItem(id) | void | Remove a command item |
unregisterItems(ids) | void | Bulk-remove items by ID set |
registerGroup(group) | Disposable | Register a group — using auto-deregisters |
unregisterGroup(id) | void | Remove a group |
getItem(id) / getGroup(id) | item/group or undefined | O(1) lookup by ID |
getItems() / getGroups() | readonly [] | All registered items/groups in insertion order |
getGroupedItems() | ReadonlyMap | Items bucketed by group ID |
getItemIds() | ReadonlySet<ItemId> | All registered item IDs |
intersectWith(ids) / differenceFrom(ids) / unionWith(ids) / symmetricDifferenceWith(ids) | ReadonlySet<ItemId> | Set operations against the registered ID set |
isSubsetOf(ids) / isSupersetOf(ids) / isDisjointFrom(ids) | boolean | Set relationship checks |
clear() | void | Remove all items and groups |
TypedEmitter
Type-safe event emitter holding strong references to listeners; on() returns a Disposable that removes the listener.
import { TypedEmitter } from 'modern-cmdk';
type Events = { change: string; count: number };
const emitter = new TypedEmitter<Events>();
emitter.on('change', (value) => console.log(value));
emitter.emit('change', 'hello');createScheduler()
Batched update scheduler with requestAnimationFrame, scheduler.yield(), and isInputPending() awareness.
import { createScheduler } from 'modern-cmdk';
const scheduler = createScheduler();
const expensiveWork = (): void => console.log('batched');
scheduler.schedule(() => expensiveWork());
// Work is batched and yields to browser when input is pendingConstants
| Constant | Value | Description |
|---|---|---|
DEFAULT_FRECENCY_DECAY | { hourWeight: 4.0, dayWeight: 2.0, weekWeight: 1.5, monthWeight: 1.0, olderWeight: 0.5 } | Default decay curve |
DEFAULT_MACHINE_OPTIONS | { loop: true, virtualizeThreshold: 100, open: false } | Default machine options |
React Adapter
For the full React 19 component API, see modern-cmdk/react.