Adapters
The engine does the filtering, ranking and grouping. An adapter connects it to a UI library, or to a router. There are two UI adapters, for cmdk and Base UI, and two route adapters: React Router, which reads routes at runtime, and sitemap, which reads the sitemap the CLI writes. Each is its own entry point, so you only ship the ones you import.
cmdk adapter
cmdk-engine/adapters/cmdk renders the palette with cmdk and turns off cmdk's own filter, so the engine owns all filtering and ordering. Install the peer cmdk (^1). It exports CommandPalette and useCommandPaletteShortcut, and it must be used inside a CommandEngineProvider.
import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk'
function CommandMenu() {
useCommandPaletteShortcut() // Cmd+K / Ctrl+K
return <CommandPalette dialog placeholder="Search commands..." />
}CommandPalette props
Both UI adapters take the same props, except vimBindings. Every prop is optional.
| Prop | Type | Default | Description |
|---|---|---|---|
| dialog | boolean | false | Render in a modal dialog with an overlay. When false, the palette is inline and always visible. Open a dialog with useCommandPaletteShortcut() or toggle(). |
| placeholder | string | palette.placeholder | Input placeholder. |
| label | string | palette.label | Accessible name of the palette and of the dialog. |
| loop | boolean | true | Wrap from the last item to the first, and back. |
| onSelect | (item: CommandItem) => void | none | Runs instead of the default handling (action, then onNavigate or href). Wins over config.onSelect. |
| renderItem | (item, score) => ReactNode | built-in row | The row content. The adapter still renders the selectable wrapper. |
| renderGroupHeading | (group) => ReactNode | the group label | The text of a group heading. |
| renderEmpty | () => ReactNode | palette.empty | Shown when nothing matches. |
| renderLoading | () => ReactNode | palette.loading | Shown while async sources load. |
| renderBreadcrumbs | (crumbs, onBack) => ReactNode | built-in trail | Shown inside a sub-menu. |
| footer | ReactNode | none | Rendered below the list. |
| container | HTMLElement | the page body | Portal target in dialog mode. |
| disablePointerSelection | boolean | false | The pointer no longer moves the highlight. Clicking an item still runs it. |
| vimBindings | boolean | true | cmdk adapter only. Ctrl+N, P, J and K move the highlight. |
| className, inputClassName, listClassName, itemClassName, groupClassName, emptyClassName, overlayClassName, contentClassName | string | none | Class names for each part. See the Styling page for which element each one reaches. |
useCommandPaletteShortcut(shortcut?)
Toggles the palette and returns { isOpen, toggle }. It works the same in both UI adapters. Call it in a component inside the provider, not in the component that renders the provider.
| Argument | What it matches |
|---|---|
a string 'k' (the default) | The key pressed with Cmd (macOS) or Ctrl. It also matches with Caps Lock on and, on non-Latin layouts such as Russian or Greek, by the physical key. Ctrl+Shift+K and AltGr combinations do not match. |
| a function New in 0.6 | (event: KeyboardEvent) => boolean decides the whole match, modifiers included, for any other shortcut. |
- Holding the keys toggles once. Matching keys are always blocked, repeats included, so a held Ctrl+K never reaches the browser's own Ctrl+K shortcut.
- Define a function outside the component, or every render re-binds the listener.
import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/cmdk'
// Cmd/Ctrl+Shift+P, as in VS Code
const isPaletteKey = (e: KeyboardEvent) =>
(e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'p'
function CommandMenu() {
useCommandPaletteShortcut(isPaletteKey)
return <CommandPalette dialog />
}Focus, markup and accessibility
- In dialog mode, focus returns to the element that had it before the dialog opened, whether it closes with Escape, the shortcut, the overlay or a selected command. If a command moves focus elsewhere, focus stays there. The Base UI adapter does the same.
- Both adapters name the results listbox with the
palette.listtranslation key (default "Suggestions"). - The empty state and the loading row render right after the results list, not inside it, because a
role="listbox"may only hold groups and options. Theircmdk-*anddata-cmdk-engine-*attributes are unchanged, so style them with[cmdk-empty]and[cmdk-loading], not as list descendants. Tests that look for the empty text insiderole="listbox"must search the palette instead. - With
@radix-ui/react-dialog1.1.x, which cmdk 1.1 can install, Radix logs "DialogContentrequires aDialogTitle" and a missingDescriptionwarning each time the dialog opens, and pointsaria-labelledbyandaria-describedbyat ids that do not exist. The dialog is still named by itsaria-label. cmdk exposes no dialog title, so the adapter cannot add one: update@radix-ui/react-dialogto 1.2 or later.
Base UI adapter New in 0.6
cmdk-engine/adapters/base-ui renders the palette with Base UI's Autocomplete (with mode="none", so the engine still owns filtering and ranking) and, with dialog, its Dialog. Install the optional peer @base-ui/react (^1.1.0). It exports CommandPalette and useCommandPaletteShortcut with the cmdk adapter's props, so switching adapters is an import-path change:
import { CommandPalette, useCommandPaletteShortcut } from 'cmdk-engine/adapters/base-ui'
// Inline: always rendered
function SearchPanel() {
return <CommandPalette placeholder="Search commands..." />
}
// Dialog: opened with Cmd+K / Ctrl+K
function CommandMenu() {
useCommandPaletteShortcut()
return <CommandPalette dialog overlayClassName="backdrop" contentClassName="palette" />
}The default renderers produce the same data-cmdk-engine-* markup as the cmdk adapter, so CSS written against those attributes carries over. cmdk's own [cmdk-*] attributes do not exist here: style the parts with the *ClassName props. Other differences:
| cmdk adapter | Base UI adapter | |
|---|---|---|
| Vim keys | vimBindings (Ctrl+N/P/J/K) | None; there is no vimBindings prop |
| Home / End | First / last item | Move the caret in the input |
| Highlighted item | [cmdk-item][data-selected="true"] | [role="option"][data-highlighted] |
| Loading row | role="progressbar", after the list | role="status" live region, after the list |
| Results change while open (async sources) | Keeps the highlighted item | Keeps the highlighted position |
| IME input | The query updates while composing | The query updates when composition ends |
Follow Base UI's quick start: give your app's root element isolation: isolate so the dialog stays on top, and for iOS 26+ Safari give the backdrop (overlayClassName) position: absolute and add body { position: relative }. Like cmdk's, the dialog is unstyled. Its visually hidden close button is labelled by the palette.close translation key. With @base-ui/react 1.1, Firefox logs a mozInputSource deprecation warning the first time the input is clicked. It comes from Base UI and is gone in later versions.
Base UI costs more than cmdk: about 44 kB min + brotli for Autocomplete and 48 kB with Dialog, versus about 14 kB for cmdk with its Radix dialog. In Node, load the adapter with either import or require, not both, or two copies of @base-ui/react run side by side; bundlers load one.
React Router adapter
cmdk-engine/adapters/react-router turns a React Router route tree into commands. It accepts the route types of React Router 6, 7 and 8, and your own route interfaces, without a cast. It reads the routes you pass in and never calls lazy().
scanRoutes(routes, options?)
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { CommandEngineProvider, useCommandRegister } from 'cmdk-engine/react'
import { scanRoutes } from 'cmdk-engine/adapters/react-router'
const routes = [
{ path: '/', element: <h1>Home</h1> },
{
path: '/billing',
element: <h1>Billing</h1>,
handle: { command: { label: 'Billing Dashboard', keywords: ['money', 'payment'] } },
},
]
const router = createBrowserRouter(routes)
const commands = scanRoutes(routes, { exclude: ['/admin/*'] })
const config = { onNavigate: (href: string) => router.navigate(href) }
function RegisterRoutes() {
useCommandRegister(commands)
return null
}
export function App() {
return (
<CommandEngineProvider config={config}>
<RegisterRoutes />
<RouterProvider router={router} />
</CommandEngineProvider>
)
}On React Router 8 there is no react-router-dom: import createBrowserRouter from react-router and RouterProvider from react-router/dom.
| Option | Default | Description |
|---|---|---|
| exclude | [] | Paths to leave out, in addition to the defaults. Each entry is an exact string, a glob or a RegExp. In a glob, * matches within one path segment and ** across segments, so /_* excludes /_internal and /users/*/settings leaves /users/list alone. A match also covers every path below it, and a trailing /* also matches the base: /admin/* excludes /admin and everything under it. /admin* also matches /administration. |
| noDefaultExclude | false | Set true to skip the default exclude list. |
| includeDynamic | false | Include routes with a dynamic segment (:id, [id] or *). They are skipped by default, because a command cannot navigate without a real value. A route with handle.command is always included. Here it is a boolean; only the CLI (includeDynamic in the config, or --include-dynamic) also accepts a list of segment names. |
What it derives
- Id: the path with
/as--, so/billing/overviewisbilling--overviewand/ishome. Ids are the frecency keys, so keep a route's path stable. - Label: from the last segment, so
/billing/overviewis "Overview" and/is "Home". - Group: from the first segment when the path has two or more, so
/billing/overviewis in "Billing". A one-segment path has no group. - Default excludes: auth routes (
/login,/logout,/signin,/signout,/signup,/register,/forgot-password,/reset-password,/verify-email), callbacks (/oauth/callback,/auth/callback,/callback), and the error pages/404,/500,/errorand/not-found.
Route metadata (handle.command)
Put handle.command on the route object to enrich its command. The scanner also falls back to route.title and route.icon. A handle returned from lazy() is not read.
| Field | Description |
|---|---|
| label | Display label. Falls back to the path-derived label. |
| description | Secondary text, also searched. |
| keywords | Extra search terms. |
| group | Group id or heading. |
| icon | A string, emoji or React element. |
| permissions | Permissions required to see the command. |
| priority | Ordering weight, higher first. |
| hidden | Keep it out of the browse list but still searchable. |
Index routes New in 0.6
An index route (index: true without a path) resolves to its parent's URL, so the index route of a pathless root becomes / (label "Home", id home). It never adds a second command for a URL another route already has: its handle.command is merged over that command instead, and the index route's fields win. Only handle.command is merged; an index route's route.title and route.icon apply only when it gets its own command. Index routes follow their parent's exclusion and the dynamic-route rule. index: true with a path is a normal path route.
Sitemap adapter New in 0.6
cmdk-engine/adapters/sitemap turns the sitemap that cmdk-engine scan writes into commands, for apps whose routes live in files, as in Next.js. It has no peer dependencies.
import { useCommandRegister } from 'cmdk-engine/react'
import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap'
import sitemap from './generated/command-routes.json'
const routeCommands = sitemapToCommands(sitemap)
function RouteCommands() {
useCommandRegister(routeCommands)
return null
}sitemapToCommands(sitemap, options?)
Takes the parsed command-routes.json, or its routes array, and returns a CommandItem[] for useCommandRegister: one { id, label, keywords, group, href } per route. Selecting one calls your onNavigate.
| Option | Description |
|---|---|
| params | Values for the :name segments that --include-dynamic keeps, inserted as given (not URL-encoded). { locale: 'en' } turns /:locale/billing into /en/billing, and '' drops the segment, giving /billing. A route with a segment left unfilled is skipped, and only own properties of params count. |
- Ids keep the
:nameplaceholders whateverparamsfills in (locale--billingin every locale), so frecency and Recent are shared across locales. - Routes with a catch-all (
*) segment are skipped.
For example, to fill a locale segment from the current locale:
import { useMemo } from 'react'
import { useCommandRegister } from 'cmdk-engine/react'
import { sitemapToCommands } from 'cmdk-engine/adapters/sitemap'
import sitemap from './generated/command-routes.json'
export function LocaleRouteCommands({ locale }: { locale: string }) {
const commands = useMemo(() => sitemapToCommands(sitemap, { params: { locale } }), [locale])
useCommandRegister(commands)
return null
}