Skip to content

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 ​

bash
pnpm add modern-cmdk
bash
npm install modern-cmdk
ts
import { 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.

ts
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 scope

CommandMachineOptions ​

OptionTypeDefaultDescription
itemsCommandItem[][]Pre-registered items to populate on creation
groupsCommandGroup[][]Pre-registered groups for item categorization
filterFunction | falsebuilt-inCustom filter function, or false to disable filtering
searchSearchEnginebuilt-inPluggable search engine (e.g., WASM engine)
labelstring'Command palette'Accessible label for the palette
openbooleanfalseInitial open state
frecencyFrecencyOptionsundefinedFrecency ranking configuration
loopbooleantrueWhether keyboard navigation wraps around
virtualizeThresholdnumber100Accepted 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 ​

MethodReturnsDescription
getState()CommandStateReturns current immutable state snapshot
send(event)voidDispatches an event (batched via requestAnimationFrame)
subscribe(listener)() => voidSubscribe to state changes (useSyncExternalStore-compatible)
subscribeState(listener)DisposableSubscribe with full state — returns Disposable for using
getRegistry()CommandRegistryAccess the command registry
getKeyboardRegistry()KeyboardShortcutRegistryAccess 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]()voidClean up all resources (search engine, frecency, listeners)

CommandState ​

Immutable state snapshot. Each mutation produces a new object reference for efficient change detection.

FieldTypeDescription
searchstringCurrent search query
activeIdItemId | nullCurrently highlighted item, or null
filteredIdsreadonly ItemId[]Ordered list of visible item IDs
groupedIdsReadonlyMap<GroupId, readonly ItemId[]>Group ID → visible item IDs
filteredCountnumberCount of visible items
loadingbooleantrue while any <Command.AsyncItems> is pending (the LOADING event)
pagestringCurrent page identifier
pageStackreadonly string[]Previous pages for back-navigation
openbooleanWhether the palette is open
lastUpdatednumberEpoch-millisecond timestamp of last mutation

Events (CommandEvent) ​

Event TypePayloadDescription
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.

ts
import { createInitialState } from 'modern-cmdk';

const state = createInitialState({ open: true });
// { search: '', activeId: null, filteredIds: [], filteredCount: 0, ... }

Types ​

CommandItem ​

FieldTypeRequiredDescription
idItemIdYesUnique identifier (branded type)
valuestringYesDisplay text and primary search target
keywordsreadonly string[]NoAdditional searchable terms
groupIdGroupIdNoGroup this item belongs to
shortcutstringNoKeyboard shortcut (e.g., "Mod+K")
disabledbooleanNoExcluded from filtering and selection
onSelect() => voidNoCallback when selected. Called with no arguments, in the core and in <Command.Item onSelect>
dataRecord<string, unknown>NoArbitrary metadata

CommandGroup ​

FieldTypeRequiredDescription
idGroupIdYesUnique identifier (branded type)
headingstringNoDisplay heading text
prioritynumberNoSort priority (lower = higher in list)

Branded Types ​

Branded types prevent accidental mixing of plain strings with IDs at compile time.

ts
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!
FunctionReturnsDescription
itemId(id)ItemIdCreate a branded item ID. Ensures well-formed Unicode.
groupId(id)GroupIdCreate 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.

ts
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 ​

MethodReturnsDescription
index(items)voidIndex items for fast lookup
search(query, items)IteratorObject<SearchResult>Search items, returns lazy iterator of scored results
remove(ids)voidRemove items from index by ID set
clear()voidClear the entire index
[Symbol.dispose]()voidDispose resources

SearchResult ​

FieldTypeDescription
idItemIdMatched item ID
scorenumberMatch quality (0–1, higher = better)
matches[number, number][]Character ranges for highlighting

ScorerFn ​

ts
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 ​

FunctionDescription
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.

ts
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.0

FrecencyOptions ​

FieldTypeDefaultDescription
enabledboolean (optional)falseTurns 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
storageFrecencyStorageMemoryFrecencyStoragePersistence backend
namespacestring'default'Storage namespace for isolation
decayConfigFrecencyDecayConfigSee belowDecay curve weights

FrecencyDecayConfig ​

FieldTypeDefaultDescription
hourWeightnumber4.0Boost for items used in the last hour
dayWeightnumber2.0Boost for items used today
weekWeightnumber1.5Boost for items used this week
monthWeightnumber1.0Boost for items used this month
olderWeightnumber0.5Boost for older items

Storage Implementations ​

ClassDescription
MemoryFrecencyStorageIn-memory storage (lost on page reload). Implements Disposable.
IdbFrecencyStorageIndexedDB persistence via idb-keyval. Implements AsyncDisposable.
ts
import { IdbFrecencyStorage } from 'modern-cmdk';

// Persist frecency data across browser sessions
await using storage = new IdbFrecencyStorage('my-db', 'frecency-store');

FrecencyStorage Interface ​

ts
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.

ts
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)
MethodReturnsDescription
register(shortcut, itemId, handler)DisposableBind a shortcut — using auto-unregisters
unregister(shortcut)voidRemove a binding by shortcut string
getConflicts()ReadonlyMap<string, readonly ParsedShortcut[]>Normalized shortcut → conflicting bindings
[Symbol.dispose]()voidRemove the global keydown listener and clear all bindings

Shortcut Utilities ​

FunctionReturnsDescription
parseShortcut(shortcut)ParsedShortcutParse "Mod+Shift+K" into structured object
formatShortcut(parsed)stringFormat back to display string
matchesShortcut(event, parsed)booleanCheck if a KeyboardEvent matches a parsed shortcut
findMatchingShortcut(event, shortcuts)ParsedShortcut | nullFind first matching shortcut
detectConflicts(shortcuts)ReadonlyMap<string, readonly ParsedShortcut[]>Detect duplicate shortcut bindings

ParsedShortcut ​

ts
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 ​

TokenMeaningmacOSWindows/Linux
ModPlatform modifierCmd (⌘)Ctrl
CtrlControl keyCtrl (⌃)Ctrl
ShiftShift keyShift (⇧)Shift
AltAlt/Option keyOption (⌥)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.

ts
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 ​

HookSignatureDescription
onPaletteOpen() => voidCalled when the palette opens
onPaletteClose(durationMs: number) => voidCalled when it closes, with session duration
onSearchComplete(query, resultCount, durationMs) => voidCalled after each search
onItemSelected(itemId, searchQuery, position) => voidCalled when an item is selected

Utilities ​

CommandRegistry ​

Internal registry for items and groups. Access via machine.getRegistry().

MethodReturnsDescription
registerItem(item)DisposableRegister a command item — using auto-deregisters
registerItems(items)DisposableBatch-register items with a single disposer
unregisterItem(id)voidRemove a command item
unregisterItems(ids)voidBulk-remove items by ID set
registerGroup(group)DisposableRegister a group — using auto-deregisters
unregisterGroup(id)voidRemove a group
getItem(id) / getGroup(id)item/group or undefinedO(1) lookup by ID
getItems() / getGroups()readonly []All registered items/groups in insertion order
getGroupedItems()ReadonlyMapItems 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)booleanSet relationship checks
clear()voidRemove all items and groups

TypedEmitter ​

Type-safe event emitter holding strong references to listeners; on() returns a Disposable that removes the listener.

ts
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.

ts
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 pending

Constants ​

ConstantValueDescription
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.