Command
The ⌘K command palette — a filterable, keyboard-navigable list of actions and records.
Command is one of crisp's Product-layer building blocks — Sidebar mounts it once and opens it from its search field or a global ⌘K / Ctrl+K listener. It composes a Zag dialog machine (modal, focus trap, scroll lock) with a Zag combobox machine (filtering, keyboard navigation) so correctness comes from the machines, not hand-rolled event handling.
726px wide, r16 dialog, 36px / r12 items. Its chrome is a specific, opinionated palette; for a generic filterable/keyboard-navigable overlay outside that exact look, compose Menu or Popover directly.
Click the button below, or press ⌘K / Ctrl+K anywhere on this page — the same shortcut Sidebar wires up.
Installation
npm install @crisp-ui-kit/crispUsage
Command is controlled — the caller owns open state and decides what opens it (a button, a keyboard shortcut, or both).
"use client";
import * as React from "react";
import { Command } from "@crisp-ui-kit/crisp";
export function Example() {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
return (
<>
<button onClick={() => setOpen(true)}>Open command palette</button>
<Command open={open} onOpenChange={setOpen} />
</>
);
}Props
Prop
Type
CommandItem
| Field | Type | Description |
|---|---|---|
id | string | Unique, stable identifier. |
label | string | Visible text, also matched against the query. |
icon | React.ComponentType | Leading 16px icon component. |
keywords | string[] | Extra terms that match this item without being shown. |
shortcut | string[] | Trailing shortcut hint, rendered as one Kbd per key (e.g. ["G", "H"]). |
secondary | string | Inline hint after the label (e.g. "Attio API documentation" next to "Developer docs") — 12px/medium, muted, baseline-aligned. |
chips | CommandItemChip[] | Type chips at the row's trailing edge ({ label, icon?, tone? }, tone "brand" or "neutral"): "Person", "Company", "Favorite". 20px, 6px corners, 12px/500 (measured on Attio). |
actionLabel | string | The footer button's label while this row is highlighted, instead of the row's label — Attio reads "Open record" on a record result. |
onSelect | () => void | Called when the item is chosen; the palette then closes. |
CommandSection
For pageSections — see Page-context sections below.
| Field | Type | Description |
|---|---|---|
title | string | Section header text — CONTENT, not a fixed label (e.g. the current view's name). |
items | CommandItem[] | This section's rows; each renders under title regardless of its own group. |
Behavior
- Filter — typing narrows
itemsto those whoselabelorkeywordsmatch the query, case-insensitively. PassfilterItems={false}if you're supplying already-filtereditemsyourself. ↑/↓— moves the highlighted item; the list wraps and auto-highlights the first match as you type.Enter— runs the highlighted item'sonSelectand closes the palette.Escape/ backdrop click — closes the palette without selecting anything.- Reopening always starts from a cleared query and a fresh highlight — it never resumes a stale filter from the previous time it was open. This applies to a controlled
querytoo:onQueryChangefires with""on open, same as any other query change. - The query is observable via
onQueryChangeand optionally controllable viaquery— see Searching your own data below.
Examples
Controlled from a button
The example in Preview above — a Button sets open to true; Command calls onOpenChange(false) on close.
<Button intent="primary" onClick={() => setOpen(true)}>
Open command palette
</Button>
<Command open={open} onOpenChange={setOpen} />Custom items
import { House, Settings } from "lucide-react";
const items = [
{ id: "home", label: "Go to Home", icon: House, shortcut: ["G", "H"] },
{ id: "settings", label: "Settings", icon: Settings, keywords: ["preferences"] },
];
<Command open={open} onOpenChange={setOpen} items={items} />;Page-context sections
defaultCommandItems is Attio's own page-agnostic set — live-measured off Home, where the palette has no current page to be about: a Quick Action group (18 items) and its own separate Help group (3 items). An object list page adds sections ABOVE that set, and three Quick Action items relocate into them rather than appearing twice — pageSections is how a consumer reproduces that without crisp needing any page/view model of its own:
import { Command, defaultCommandItems, type CommandSection } from "@crisp-ui-kit/crisp";
import { Mail, Star, Sparkles, LayoutGrid, Columns3 } from "lucide-react";
const pageSections: CommandSection[] = [
{
title: "All Companies", // the CURRENT VIEW's name, not a fixed "All {Object}"
items: [
{ id: "compose-email", label: "Compose email", icon: Mail, secondary: "To Selected", shortcut: ["C"] },
{ id: "add-to-favorites", label: "Add to favorites", icon: Star, secondary: "All Companies" },
{ id: "ask-attio-view", label: "Ask Attio", icon: Sparkles, secondary: "All Companies", shortcut: ["⌘", "J"] },
],
},
{
title: "Companies", // the object's plural name
items: [
{ id: "create-view", label: "Create view", icon: LayoutGrid, secondary: "Companies" },
{ id: "create-column", label: "Create column", icon: Columns3, secondary: "All Companies" },
],
},
];
// omit Ask Attio/Compose email from the page-agnostic set — pageSections
// above already covers them for this page, scoped to the current view
const items = defaultCommandItems.filter(
(item) => item.id !== "ask-attio" && item.id !== "compose-email",
);
<Command open={open} onOpenChange={setOpen} items={items} pageSections={pageSections} />;Record results, "See more" and "Create"
Attio's palette marks each record result with a type chip, sets the footer button to "Open record" while one is highlighted, and ends the list with See more results for "<query>" and Create "<query>" as record. All four are opt-in (carrot-systems/crisp#1158): chips and actionLabel on an item, and the seeMoreItem / createItem props, each a function of the trimmed query that returns a CommandItem (or null to omit it). The two trailing rows appear only while a query is typed, skip the built-in filter, and take part in arrow-key navigation like any other row.
Searching your own data
Command's built-in filter only ever matches against items' own label/keywords, so it can't reach a host app's own records (transactions, customers, …). Control query and turn off the built-in filter with filterItems={false}, then swap items for whatever your own search returns as the query changes:
"use client";
import * as React from "react";
import { Command, type CommandItem } from "@crisp-ui-kit/crisp";
import { CircleDollarSign } from "lucide-react";
export function Example({ open, setOpen }: { open: boolean; setOpen: (v: boolean) => void }) {
const [query, setQuery] = React.useState("");
const [items, setItems] = React.useState<CommandItem[]>([]);
React.useEffect(() => {
if (!query.trim()) return setItems([]);
const controller = new AbortController();
searchTransactions(query, controller.signal).then((results) =>
setItems(
results.map((t) => ({
id: t.id,
label: t.description,
icon: CircleDollarSign,
onSelect: () => openTransaction(t.id),
})),
),
);
return () => controller.abort();
}, [query]);
return (
<Command
open={open}
onOpenChange={setOpen}
items={items}
query={query}
onQueryChange={setQuery}
filterItems={false}
emptyLabel={query ? "No matching transactions" : "Type to search…"}
/>
);
}Accessibility
The dialog is rendered through a Portal and traps focus while open — Tab cycles only through its own controls, and focus returns to the trigger on close. The search input receives initial focus automatically. The option list uses role="option" / aria-selected (from the combobox machine), so the highlighted item is announced to screen readers as the keyboard moves. The whole dialog carries aria-label="Command palette" by default — pass the aria-label prop to translate it.
| Key | Action |
|---|---|
⌘K / Ctrl+K | Opens the palette (when the host wires up the listener, as Sidebar does). |
↑ / ↓ | Moves the highlighted item. |
Enter | Runs the highlighted item and closes the palette. |
Escape | Closes the palette. |
Tab / Shift+Tab | Cycles focus within the dialog only (focus trap). |