Composites

Command Palette

The ⌘K palette, in the Linear/Raycast mould: grouped commands, shortcut hints, and search that ignores Vietnamese diacritics so tao viec finds Tạo việc mới. It lives at the composite tier rather than in the shell, because a standalone settings page or an internal tool wants a palette without buying a whole app frame.

Every demo on this page is the real component. Press anywhere on this page to open the first one — it is the only palette here with registerShortcut left on, for the reason in Owning the shortcut below.

Basic usage

The palette is always controlled: you own open, it owns everything inside. Items carry an icon component, an optional muted second line, and an optional shortcut hint on the right. Selecting a row closes the palette first and calls onSelect after, so a navigation never races the closing animation.

Nothing run yet.

The last row in Actions is disabled. It still appears in the list and still matches the search — arrow keys skip it and onSelect never fires. Keeping an unavailable command visible with a reason beside it beats hiding it and letting the reader wonder where it went.

Diacritic-insensitive search

The palette replaces cmdk’s default scorer with one that runs both sides through removeVietnameseTones() before a plain substring test. Nobody types Nguyễn with the tone marks while looking for a colleague. Open the palette below and try nguyen, duc, tran minh or ha.

No profile opened.

Two things to know about the matcher. First, đ is a separate Unicode letter rather than d plus a combining mark, so NFD normalisation alone would not strip it — it gets an explicit replacement rule, which is why duc finds Đức. Second, the test is a contiguous substring match over `${id} ${label} ${description} ${keywords}`, not a fuzzy or per-word one: tran dang will not find Trần Minh Đăng, because the words are not adjacent. That is what keywords is for — put the shortened form, the English job title and the employee code there.

The recent group

recent pins a list above every group, but only while the query is empty — the guard is recent.length > 0 && search.trim() === ''. As soon as someone types, recency stops being a useful ranking and gets out of the way. Pick a few apps below, then reopen the palette.

recent = []

A recent entry and its twin in a group are two separate rows. The component gives the recent one the cmdk value `recent-${id}` for exactly that reason: cmdk keys options by value and silently merges duplicates, so without the prefix the pinned copy and the real one would fight over one slot.

Server-side search

Client filtering runs out of road once the index is bigger than the page. Passing onSearchChange at all flips the palette into server mode — internally shouldFilter={false} — and from then on the palette renders groups verbatim. If you pass the handler but keep filtering client-side, nothing will narrow. Try invoice or report below; there is a deliberate 700 ms delay so the spinner is visible.

query = "" · loading = false
Nothing opened.

loading does two jobs: it swaps the magnifier for a spinner, and it suppresses CommandEmpty entirely. Without the second half, every keystroke would flash “No results” for as long as the request took. The query is cleared whenever the palette closes, so the next open starts blank rather than resurrecting a stale search.

Footer slot

footer renders a bordered strip along the bottom of the panel, styled at text-xs text-muted-foreground in a flex row. It is where a key legend or a result count goes; skip it and the panel simply ends after the list.

Owning the shortcut

By default the palette attaches its own keydown listener to document and toggles on Cmd/Ctrl+K — so the same key closes it again. That is the right default standalone, and the wrong one inside the shell.

  • Inside AppShell, pass registerShortcut={false}. ShellProvider already binds ⌘K to its own commandPaletteOpen. Two listeners toggling one boolean cancel out: the palette opens and closes inside a single keypress.
  • Mounting more than one palette? Same rule — only one may register. That is why every demo on this page except the first passes false.
  • The listener compares event.key against the literal 'k' — no lower-casing, no Shift check. Anything that changes the character therefore slips past it: ⌘⇧K reports 'K' and does not open the palette, which is lucky given the shell binds that combination to the App Launcher, but ⌘K also stops working while Caps Lock is on. The shell’s own handler lower-cases first; one more reason to hand it the shortcut when there is one.
  • It also has no “is the reader typing” guard, unlike the shell’s handler: ⌘K works from inside a text field. Usually what you want, worth knowing when your app binds ⌘K to something else in an editor.
tsx
import { useState } from 'react'
import { CommandPalette, type CommandPaletteGroup } from '@comitor/ui'
import { useShell } from '@comitor/ui/shell'

// Standalone — the palette binds Cmd/Ctrl+K itself (registerShortcut defaults to true).
export function Standalone({ groups }: { groups: CommandPaletteGroup[] }) {
  const [open, setOpen] = useState(false)
  return <CommandPalette open={open} onOpenChange={setOpen} groups={groups} />
}

// Inside AppShell — ShellProvider ALREADY owns Cmd/Ctrl+K (and Cmd/Ctrl+Shift+K for the
// App Launcher). Leave the palette's own listener on and both handlers flip the same
// state in one keypress: it opens and closes again before you let go of the key.
export function InShell({ groups }: { groups: CommandPaletteGroup[] }) {
  const { commandPaletteOpen, setCommandPaletteOpen } = useShell()
  return (
    <CommandPalette
      open={commandPaletteOpen}
      onOpenChange={setCommandPaletteOpen}
      groups={groups}
      registerShortcut={false}
    />
  )
}

Writing it in English

The palette’s three visible strings are plain props, all Vietnamese by default:

  • placeholder "Tìm lệnh, trang, người…"
  • emptyText "Không tìm thấy kết quả."
  • recentLabel "Gần đây"

Two more strings are never seen but always heard: the sr-only dialog title ("Bảng lệnh") and its description. Both are required parts of a dialog, so a screen reader announces them every time the palette opens. They travel together and change only with language, so they are the one place the palette takes a labels bag rather than loose props: pass labels={{ title: 'Command palette', description: '…' }}. It is spread over DEFAULT_COMMAND_PALETTE_LABELS (exported from @comitor/ui), so overriding one key keeps the other. Note also that placeholder is set as the input’s aria-label, so it is doing double duty as the accessible name of the search field — write it as a name, not as a nudge.

CommandPalette props

PropTypeDefaultDescription
openrequiredbooleanControlled open state. There is no uncontrolled mode — the palette is always driven from outside, because whoever owns the ⌘K state usually owns the shell too.
onOpenChangerequired(open: boolean) => voidCalled by the dialog, by the built-in ⌘K listener, and with false immediately before an item runs.
groupsrequiredCommandPaletteGroup[]The command list, rendered in the order given. A group whose items are all filtered out hides itself — you never have to prune the array as the reader types.
recentCommandPaletteItem[][]Pinned above every group while the query is empty. Hidden as soon as the reader types anything.
recentLabelstring'Gần đây'Heading of the recent group. Vietnamese by default — pass "Recent".
placeholderstring'Tìm lệnh, trang, người…'Input placeholder — and, because it is also set as aria-label, the accessible name of the search field.
emptyTextstring'Không tìm thấy kết quả.'Shown when nothing matches. Suppressed entirely while loading is true.
labelsPartial<CommandPaletteLabels>DEFAULT_COMMAND_PALETTE_LABELStitle · description — the two sr-only strings of the dialog. Vietnamese by default, and spread over the defaults, so passing one key keeps the other.
onSearchChange(search: string) => voidPresence alone switches off cmdk’s internal filtering — the palette then renders groups exactly as given. Use for server-side search.
loadingbooleanfalseReplaces the magnifier with a spinner and hides the empty state so an in-flight request never flashes "no results".
footerReactNodeStrip along the bottom edge, above the border. Legend of keys, result count, index freshness.
registerShortcutbooleantrueBinds a document-level Cmd/Ctrl+K listener that TOGGLES open. Set false inside AppShell, where ShellProvider already binds it.
classNamestringMerged onto the DialogContent panel (which already carries size="lg", overflow-hidden and p-0).

CommandPaletteItem

PropTypeDefaultDescription
idrequiredstringReact key, cmdk value, and part of the search index — so give it words, not numbers.
labelrequiredstringPrimary line. Truncates rather than wrapping.
onSelectrequired() => voidRuns after the palette has closed. Skipped entirely when disabled is true.
descriptionstringMuted second line — the route, the workspace, the email address. Also searchable.
iconIconComponentIcon COMPONENT, e.g. icon={FileText}. Rendered at 16px in muted-foreground and aria-hidden.
shortcutstringKey combo shown on the right via KeyboardHint, e.g. "mod+shift+n". mod renders ⌘ on macOS and Ctrl elsewhere. This is a HINT — it does not bind anything.
keywordsstring[]Extra terms folded into the search index: English names for Vietnamese labels, abbreviations, record codes, common misspellings.
disabledbooleanfalseDims the row to 50%, removes it from arrow-key navigation, and short-circuits onSelect. Pair it with a description that says why.

CommandPaletteGroup

PropTypeDefaultDescription
idrequiredstringReact key for the group.
itemsrequiredCommandPaletteItem[]Rows in this group, in display order.
labelstringGroup heading. Omit it for an unlabelled block of rows.

Both types are exported from @comitor/ui, so command registries can be typed at the edge of your app rather than inferred at the call site.

Accessibility

  • The panel is a Radix Dialog: role="dialog" with aria-modal, a focus trap, Esc to close, and focus returned to whatever opened it. It renders with showCloseButton={false} — there is no visible ✕, so Esc and the overlay are the two exits, and the Esc hint in the search row is the only affordance that says so.
  • An sr-only DialogTitle and DialogDescription are always rendered. Radix requires a title on every dialog and warns in the console without one; the description explains the interaction model (“type to search; arrows to choose; Enter to run”) to someone who cannot see the key legend.
  • cmdk supplies the listbox semantics: the input is role="combobox" with aria-expanded, aria-controls and aria-activedescendant; the list is role="listbox"; each row is role="option" with aria-selected; each group is role="group" labelled by its heading. Focus never leaves the input — the highlight moves via aria-activedescendant, which is why typing and navigating do not fight each other.
  • Keyboard: ↑/↓ move the highlight straight across group boundaries and stop at the ends — cmdk’s loop is off here, so the list never wraps around under you. Enter runs the highlighted row, Esc closes, and Cmd/Ctrl+K toggles when registerShortcut is on. cmdk keeps the first match highlighted as you type, so Enter always has a target.
  • disabled items get aria-disabled="true"; cmdk’s own item selector excludes them, so arrow keys step over them, and onSelect is short-circuited as a second guard.
  • Every KeyboardHint — the Esc badge, the per-row shortcuts, anything you put in footer — is aria-hidden, because “⌘⇧↵” read aloud is noise. That makes the label and description the whole accessible name of a row, and it means your own trigger button must carry its own name: aria-label="Open command palette, Ctrl K".
  • Row icons are aria-hidden and tinted text-muted-foreground; the highlighted row uses bg-accent with text-accent-foreground — the paired background/foreground role tokens, so the highlight holds up in both palettes and both themes without a dark: override.
  • The shortcut hints render Ctrl on the first client render even on a Mac, then flip to ⌘. That is useIsMac refusing to guess the platform on the server: a one-frame label change is cheaper than a hydration mismatch that can throw away the subtree.