Components

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.

Available since@crisp-ui-kit/crisp@0.11.0

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/crisp

Usage

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

FieldTypeDescription
idstringUnique, stable identifier.
labelstringVisible text, also matched against the query.
iconReact.ComponentTypeLeading 16px icon component.
keywordsstring[]Extra terms that match this item without being shown.
shortcutstring[]Trailing shortcut hint, rendered as one Kbd per key (e.g. ["G", "H"]).
secondarystringInline hint after the label (e.g. "Attio API documentation" next to "Developer docs") — 12px/medium, muted, baseline-aligned.
chipsCommandItemChip[]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).
actionLabelstringThe footer button's label while this row is highlighted, instead of the row's label — Attio reads "Open record" on a record result.
onSelect() => voidCalled when the item is chosen; the palette then closes.

CommandSection

For pageSections — see Page-context sections below.

FieldTypeDescription
titlestringSection header text — CONTENT, not a fixed label (e.g. the current view's name).
itemsCommandItem[]This section's rows; each renders under title regardless of its own group.

Behavior

  • Filter — typing narrows items to those whose label or keywords match the query, case-insensitively. Pass filterItems={false} if you're supplying already-filtered items yourself.
  • ↑ / ↓ — moves the highlighted item; the list wraps and auto-highlights the first match as you type.
  • Enter — runs the highlighted item's onSelect and 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 query too: onQueryChange fires with "" on open, same as any other query change.
  • The query is observable via onQueryChange and optionally controllable via query — 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.

KeyAction
⌘K / Ctrl+KOpens the palette (when the host wires up the listener, as Sidebar does).
↑ / ↓Moves the highlighted item.
EnterRuns the highlighted item and closes the palette.
EscapeCloses the palette.
Tab / Shift+TabCycles focus within the dialog only (focus trap).

On this page