Search

cmdk-engine owns all filtering: the adapters turn off the UI library's own filter, and every result comes from one pipeline. This page covers how results are found and ranked, and how to change that.

The pipeline

Each time the query changes, the engine runs these steps over the commands at the current level:

  1. Remove commands whose when resolves to false.
  2. Remove commands the user's permissions do not allow (accessControl).
  3. Search with the built-in fuzzy search, match-sorter or your own engine, and add synonym matches.
  4. With a query, blend in frecency, then boost commands whose scope matches context. With an empty query, show the Recent group if it is enabled, and rank by frecency.
  5. Cut to maxResults (default 50) and group the results.

Commands loaded from an async source take part in step 3 when its shouldFilter is true, the default. See asyncSources in the API Reference.

Built-in search

createFuzzySearch() is the default engine. It scores a command against its label, keywords, description and synonyms, and keeps the best score. Each field has a weight:

FieldWeight
label1
keywords0.85
description0.7
synonyms from config.synonyms0.55

Within a field, the kind of match sets the score. The examples search a command labelled "Billing Settings":

MatchScoreExample query
Exact1billing settings
Prefix0.95bill
Substring0.8settings
Word initials0.7bs
Characters in order, with gapsup to 0.6biling
  • Matches that are too scattered score under 0.15 and are dropped.
  • Results are ordered by score, then by priority (higher first).
  • An empty query lists every command that is not hidden, ordered by priority. A hidden command is still found by a non-empty query.

Words in any order New in 0.6

The words of a query can match in any order, and each word can match a different field: "overview billing" finds "Billing Overview", and "invoices billing" finds a "Billing" command with the keyword "invoices". Every word has to match something, and a repeated word counts once. These matches come after the commands that match the whole query, which keep their order and scores, and never score above the weakest of them.

Accents, Unicode forms and spaces New in 0.6

The query and the commands are compared after Unicode compatibility decomposition (NFKD), with the combining accents U+0300 to U+036F removed, in lowercase, with repeated whitespace collapsed. So "resume" finds "Résumé", a decomposed "café" finds a composed one, and billing  over (two spaces) finds "Billing Overview". Other marks (Indic vowel signs, kana voicing marks) are kept, ß and dotless ı are not folded, and the built-in search compares Korean by its letters (jamo), so a partial syllable already matches. ASCII text with single spaces scores as before, while ASCII text with repeated spaces, tabs or line breaks (a multi-line description, for example) now also matches across them. Pass your own searchEngine if you need accent-sensitive matching.

Synonyms

Synonyms work both ways. With this config, typing "money" or "payment" finds a command labelled "Billing Overview":

tsx
const config = {
  synonyms: {
    billing: ['money', 'payment', 'credits'],
    settings: ['preferences', 'config', 'options'],
  },
}
  • Query: when the whole query (ignoring case, accents and extra spaces) equals a key or a value, the other terms are searched too: a key brings its values, a value its key. Commands found only this way are listed after the direct matches and never score above the weakest one. Frecency and context boosts apply afterwards, so a command you use often can still move up.
  • Commands: a command whose keyword or whole label equals a key or a value also matches the other terms, at a lower weight (with match-sorter, ranked at most CONTAINS).
  • Not expanded: the query, while it is a partial word ("mon" is searched as typed until "money" is complete) or a longer phrase that contains a synonym ("money transfer").

When the query expands, a custom searchEngine is called once more for each extra term.

match-sorter

cmdk-engine/search/match-sorter swaps in match-sorter, which ranks by how closely a command matches. Install the optional peer match-sorter (7 or 8): the entry imports it directly, so it must be installed for this entry, and results do not change once it loads. Pass the engine in the provider config, and create it once, outside the component:

tsx
import { rankings } from 'match-sorter'
import { createMatchSorterSearch } from 'cmdk-engine/search/match-sorter'

const searchEngine = createMatchSorterSearch({
  threshold: rankings.CONTAINS, // the default also accepts scattered letters
  keys: ['meta.team'], // searched in addition to label, description and keywords
})

export const config = { searchEngine }
OptionDefaultDescription
thresholdmatch-sorter's ownThe lowest match quality to keep, from rankings. The default is rankings.MATCHES, so "bng" finds "Billing"; use rankings.CONTAINS to require the query as a substring.
keysnoneExtra fields to search, as property paths such as meta.team. The label, description and keywords are always searched.
  • The query's accents, Unicode forms and extra spaces are folded the same way as in the built-in search, but its case is kept: an exact-case match ranks first.
  • A command also matches its synonym keywords, ranked at most CONTAINS so they stay below direct matches: "prefer" finds a "Settings" command with the synonym "preferences". A threshold above CONTAINS leaves synonym matches out.
  • Words in any order work too, appended after the whole-query matches. A keystroke with several words takes about 1.5 to 2 times as long as match-sorter alone on Node 22 and 2 to 3 times on Node 20, the most for three or more words.
  • match-sorter becomes part of the bundle that imports this entry, so import the entry lazily to keep it out of your main bundle. Server-side rendering loads it with the module. Without the package installed, the import fails at build or server start.

A custom engine

Any object with a search(query, items) method works. It receives the commands that passed the visibility and permission checks, and returns the matches as ScoredItem objects, best first, with scores from 0 to 1. The engine is also called for an empty query, which should return the browse list (leave out items with hidden). Any-order matching and accent folding belong to the built-in engine and the match-sorter entry; a custom engine decides for itself.

tsx
import type { SearchEngine } from 'cmdk-engine'

// Match labels that start with the query.
const prefixSearch: SearchEngine = {
  search(query, items) {
    const q = query.trim().toLowerCase()
    return items
      .filter((item) => (q ? item.label.toLowerCase().startsWith(q) : !item.hidden))
      .map((item) => ({ item, score: 1 }))
  },
}

// Define it once, outside the component: a new engine on every render rebuilds the pipeline.
export const config = { searchEngine: prefixSearch }