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.

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

PropTypeDefaultDescription
dialogbooleanfalseRender in a modal dialog with an overlay. When false, the palette is inline and always visible. Open a dialog with useCommandPaletteShortcut() or toggle().
placeholderstringpalette.placeholderInput placeholder.
labelstringpalette.labelAccessible name of the palette and of the dialog.
loopbooleantrueWrap from the last item to the first, and back.
onSelect(item: CommandItem) => voidnoneRuns instead of the default handling (action, then onNavigate or href). Wins over config.onSelect.
renderItem(item, score) => ReactNodebuilt-in rowThe row content. The adapter still renders the selectable wrapper.
renderGroupHeading(group) => ReactNodethe group labelThe text of a group heading.
renderEmpty() => ReactNodepalette.emptyShown when nothing matches.
renderLoading() => ReactNodepalette.loadingShown while async sources load.
renderBreadcrumbs(crumbs, onBack) => ReactNodebuilt-in trailShown inside a sub-menu.
footerReactNodenoneRendered below the list.
containerHTMLElementthe page bodyPortal target in dialog mode.
disablePointerSelectionbooleanfalseThe pointer no longer moves the highlight. Clicking an item still runs it.
vimBindingsbooleantruecmdk adapter only. Ctrl+N, P, J and K move the highlight.
className, inputClassName, listClassName, itemClassName, groupClassName, emptyClassName, overlayClassName, contentClassNamestringnoneClass 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.

ArgumentWhat 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.
tsx
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.list translation 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. Their cmdk-* and data-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 inside role="listbox" must search the palette instead.
  • With @radix-ui/react-dialog 1.1.x, which cmdk 1.1 can install, Radix logs "DialogContent requires a DialogTitle" and a missing Description warning each time the dialog opens, and points aria-labelledby and aria-describedby at ids that do not exist. The dialog is still named by its aria-label. cmdk exposes no dialog title, so the adapter cannot add one: update @radix-ui/react-dialog to 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:

tsx
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 adapterBase UI adapter
Vim keysvimBindings (Ctrl+N/P/J/K)None; there is no vimBindings prop
Home / EndFirst / last itemMove the caret in the input
Highlighted item[cmdk-item][data-selected="true"][role="option"][data-highlighted]
Loading rowrole="progressbar", after the listrole="status" live region, after the list
Results change while open (async sources)Keeps the highlighted itemKeeps the highlighted position
IME inputThe query updates while composingThe 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?)

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

OptionDefaultDescription
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.
noDefaultExcludefalseSet true to skip the default exclude list.
includeDynamicfalseInclude 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/overview is billing--overview and / is home. Ids are the frecency keys, so keep a route's path stable.
  • Label: from the last segment, so /billing/overview is "Overview" and / is "Home".
  • Group: from the first segment when the path has two or more, so /billing/overview is 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, /error and /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.

FieldDescription
labelDisplay label. Falls back to the path-derived label.
descriptionSecondary text, also searched.
keywordsExtra search terms.
groupGroup id or heading.
iconA string, emoji or React element.
permissionsPermissions required to see the command.
priorityOrdering weight, higher first.
hiddenKeep 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.

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

OptionDescription
paramsValues 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 :name placeholders whatever params fills in (locale--billing in 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:

tsx
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
}