Mischief

07 / Wayfinding

Command Palette

A search dialog over anything you can list, opened from a keyboard shortcut, with ranked matches and hidden keywords.

Type to filter, arrows to move, Enter to pick.

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/command-palette
import { CommandPalette } from "mischief-ui/command-palette"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/command-palette/command-palette.tsx
"use client" import * as React from "react"import { Search } from "lucide-react"import { cn } from "@/lib/utils" export type CommandItem = {  id: string  label: string  description?: string  group?: string  /** Extra words that should match, without being shown. */  keywords?: string[]}

Usage

const items = [
  { id: "hold-button", label: "Hold Button", group: "Controls" },
  { id: "redaction", label: "Redaction", group: "Documents" },
]

export function Search() {
  return <CommandPalette items={items} onSelect={(item) => open(item.id)} />
}

How matches are ranked

Everything is matched case-insensitively against the trimmed query, and each item is scored by the strongest thing it matched. Lower wins, and ties are broken alphabetically by label, so the order never depends on the order you passed items in.

RankMatch
0The label is exactly the query
1The label starts with the query
2The label contains the query
3The group contains the query
4A keyword contains the query
5The description contains the query

An item matching none of these is dropped rather than ranked last. Use keywords for the words people actually type that are not in the label -- the old name for a thing, a synonym, the noun rather than the verb.

const items = [
  {
    id: "redaction",
    label: "Redaction",
    group: "Documents",
    description: "Mark regions to black out",
    keywords: ["privacy", "black bar", "hide", "gdpr"],
  },
]
Typing privacy finds this even though the label never says it.

Ranking it yourself

The built-in tiers suit labels and keywords. When they do not -- fuzzy matching, a field the component knows nothing about, a weighting that puts recent things first -- pass rank and score the items yourself. Lower is a better match, and false drops one.

import { rankCommandItem } from "mischief-ui/command-palette"

<CommandPalette
  items={items}
  rank={(item, query) =>
    item.pinned ? -1 : rankCommandItem(item, query)
  }
/>
The built-in ranker is exported, so yours can defer to it rather than reproduce it.

The query arrives as it was typed rather than lowercased, so a ranker of your own can be case-sensitive. Turn filtering off entirely with filter={false} when the ordering is already someone else's decision.

Results from a server

The palette filters and ranks whatever array it is given, which is right when the whole set is already in the browser. Once results come from a search endpoint, two things change: you need to know what was typed, and the palette must stop re-ranking what the server already ordered.

const [query, setQuery] = useState("")
const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false)

useEffect(() => {
  if (!query) return setHits([])
  const controller = new AbortController()

  setLoading(true)
  search(query, { signal: controller.signal })
    .then(setHits)
    .finally(() => setLoading(false))

  return () => controller.abort()
}, [query])

<CommandPalette
  items={hits}
  filter={false}
  loading={loading}
  onQueryChange={setQuery}
/>
Debouncing and aborting stay yours: only you know what the endpoint costs.

While loading, the palette says it is searching rather than reporting that nothing matched, because an empty list mid-flight is not an answer. The listbox is marked busy at the same time, so a screen reader is told to wait instead of hearing an empty set.

One palette per chord

The shortcut is bound to the window, so every palette on the page hears it. Two of them on the same chord used to open two stacked dialogs from a single keypress, which is how this page found the bug: the site's own search already owns Mod+K.

A palette now ignores a keypress something else has already claimed, so the first listener wins and nobody gets a stack of modals. That is a guard against a mistake rather than a licence to make it, because which palette wins depends on mount order. Give the second one its own chord.

<CommandPalette items={pages} />
<CommandPalette items={actions} shortcut="j" />
<CommandPalette items={help} shortcut={false} />
Mod+K, Mod+J, and one opened from your own code.

The same holds for a chord your page handles itself: if your listener calls preventDefault, the palette leaves that keypress alone.

API

itemsCommandItem[]Id and label, plus an optional group, description, and keywords that match without being shown.
onSelect(item: CommandItem) => voidRuns with the chosen item. Navigate or act from here.
open, defaultOpen, onOpenChangebooleanWhether the dialog is showing, controlled or uncontrolled.
shortcutstring | falseKey used with Meta or Control. Defaults to "k". Pass false to bind nothing.
maxResultsnumberHow many matches to show. Defaults to 8.
onQueryChange(query: string) => voidCalled as the query changes, for fetching results yourself.
loadingbooleanSays results are on their way. Pair it with onQueryChange.
loadingMessageReactNodeShown while waiting. Defaults to "Searching…".
filterbooleanRank and filter here. Turn off when the server already did.
rank(item, query) => number | falseScore items yourself. Lower is better; false drops one.
placeholder, label, emptyMessagestring, string, (query) => ReactNodeCopy for the field, the dialog, and the no-match state.

CommandItem

idstringUnique within the set.
labelstringWhat is shown and matched first.
descriptionstringA second line, matched last.
groupstringHeading the item is listed under, and matched.
keywordsstring[]Words that should find the item but are not shown.

Accessibility

The field is a combobox owning a listbox, and the highlighted option is reported through aria-activedescendant, so arrow keys move the selection while focus stays in the field and typing is never interrupted. It is a native dialog opened as a modal, which brings the focus trap, the escape key, and inert content behind it without rebuilding any of them. A search that matches nothing says so in a status region rather than showing an empty list.