# CommandPalette

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/commandpalette/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

A search field above the page that finds commands and places as you type (also called a command menu or quick switcher). Reach for it when an application has more actions and pages than any menu can show.

```ts
import { CommandPalette, useCommandPaletteShortcut } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/commandpalette/

It matches by subsequence, not by substring: “ack” finds “Acknowledge the alert”, and the matched letters are marked so an unexpected find explains itself. A candidate's group is searched too.

A candidate is an id, a label and a group; what happens on choosing is yours, so pages and commands live in one list.

Without `restingItems` the empty palette is only the field. The library keeps no memory: pass recent choices as `restingItems` and rank them with `weight`.

## Examples

### Run a command

Typing finds candidates by subsequence – “ack” finds “Acknowledge the alert”; `onChoose` reports the id and you decide what happens.

```tsx
import { useState } from "react";
import { Button, CommandPalette, Stack, Text } from "@umriss-ui/core";

const COMMANDS = [
  { id: "ack", label: "Acknowledge the alert", group: "Incident INC-1048" },
  { id: "page", label: "Page the secondary on call", group: "Incident INC-1048" },
  { id: "status", label: "Post a status update", group: "Incident INC-1048" },
  { id: "resolve", label: "Resolve the incident", group: "Incident INC-1048" },
  { id: "checkout", label: "Open Checkout", group: "Services" },
  { id: "billing", label: "Open Billing", group: "Services" },
  { id: "rota", label: "Show this week's on-call rota", group: "People" },
];

export default function Commands() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState("-");

  return (
    <Stack gap={3} align="flex-start">
      <Button onClick={() => setOpen(true)}>Open the palette</Button>
      <Text size="xs" tone="muted">
        Last run: {last}
      </Text>
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        items={COMMANDS}
        restingItems={COMMANDS}
        onChoose={(id) => {
          setOpen(false);
          setLast(COMMANDS.find((c) => c.id === id)?.label ?? id);
        }}
      />
    </Stack>
  );
}
```

### Show recent choices first

`restingItems` fills the empty palette with the last three choices, and a `weight` ranks them higher while typing; the memory is yours.

```tsx
import { useState } from "react";
import { Button, CommandPalette, Stack, Text } from "@umriss-ui/core";
import type { CommandPaletteItem } from "@umriss-ui/core";

const PLACES: CommandPaletteItem[] = [
  { id: "CC-1100", label: "Sales", group: "Cost centres" },
  { id: "CC-1200", label: "Marketing", group: "Cost centres" },
  { id: "CC-2100", label: "Engineering", group: "Cost centres" },
  { id: "CC-3100", label: "Customer service", group: "Cost centres" },
  { id: "CC-4300", label: "IT", group: "Cost centres" },
  { id: "INV-26-0318", label: "INV-26-0318 Brandlow Office Supply", group: "Invoices" },
  { id: "INV-26-0317", label: "INV-26-0317 Kettering & Shaw Events", group: "Invoices" },
];

export default function RecentFirst() {
  const [open, setOpen] = useState(false);
  const [recent, setRecent] = useState<string[]>(["CC-1200", "INV-26-0317"]);

  const items = PLACES.map((place) => (recent.includes(place.id) ? { ...place, weight: 2 } : place));
  const resting = recent.map((id) => items.find((item) => item.id === id)!).map((item) => ({ ...item, group: "Recent" }));

  return (
    <Stack gap={3} align="flex-start">
      <Button onClick={() => setOpen(true)}>Go to …</Button>
      <Text size="xs" tone="muted">
        Recent: {recent.join(", ")}
      </Text>
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        items={items}
        restingItems={resting}
        onChoose={(id) => {
          setOpen(false);
          setRecent((list) => [id, ...list.filter((one) => one !== id)].slice(0, 3));
        }}
      />
    </Stack>
  );
}
```

### Open it with a shortcut

[`useCommandPaletteShortcut`](https://romanhaendler.github.io/umriss-ui/core/api/#useCommandPaletteShortcut) binds Cmd-K or Ctrl-K and “/” – except inside a text field, where a slash stays a slash.

```tsx
import { useCallback, useState } from "react";
import { CommandPalette, Input, Stack, Text, useCommandPaletteShortcut } from "@umriss-ui/core";

const PROJECTS = [
  { id: "shop", label: "Online shop relaunch", group: "Pembury Home" },
  { id: "booking", label: "Booking app", group: "Saltmarsh Clinics" },
  { id: "portal", label: "Member portal", group: "Rowan Credit Union" },
  { id: "intranet", label: "Intranet", group: "Tidewell (internal)" },
];

export default function Shortcut() {
  const [open, setOpen] = useState(false);
  const [text, setText] = useState("");
  const [project, setProject] = useState("-");

  useCommandPaletteShortcut(useCallback(() => setOpen(true), []));

  return (
    <Stack gap={3} align="flex-start">
      <Text size="sm" tone="secondary">
        Press Cmd-K, Ctrl-K or “/” to jump to a project. Current: {project}
      </Text>
      <Input
        size="sm"
        aria-label="Here a slash stays a slash"
        value={text}
        onChange={(event) => setText(event.target.value)}
        placeholder="Type “/” here"
      />
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        items={PROJECTS}
        onChoose={(id) => {
          setOpen(false);
          setProject(PROJECTS.find((p) => p.id === id)?.label ?? id);
        }}
      />
    </Stack>
  );
}
```

### Find a command by its synonyms

`keywords` lets “snooze” find “Mute the alert for an hour”: they are searched after the label and the group, as a run of three letters or more, and their finds stand last and unmarked.

```tsx
import { useState } from "react";
import { Button, CommandPalette, Stack, Text } from "@umriss-ui/core";
import type { CommandPaletteItem } from "@umriss-ui/core";

const COMMANDS: CommandPaletteItem[] = [
  { id: "mute", label: "Mute the alert for an hour", group: "Incident INC-1048", keywords: ["snooze", "silence"] },
  { id: "page", label: "Page the secondary on call", group: "Incident INC-1048", keywords: ["escalate", "notify"] },
  { id: "status", label: "Post a status update", group: "Incident INC-1048", keywords: ["announce", "broadcast"] },
  { id: "resolve", label: "Resolve the incident", group: "Incident INC-1048", keywords: ["close", "done"] },
];

export default function Synonyms() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState("-");

  return (
    <Stack gap={3} align="flex-start">
      <Button onClick={() => setOpen(true)}>Find a command</Button>
      <Text size="xs" tone="muted">
        Try “snooze”, “escalate” or “close”. Last run: {last}
      </Text>
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        items={COMMANDS}
        onChoose={(id) => {
          setOpen(false);
          setLast(COMMANDS.find((c) => c.id === id)?.label ?? id);
        }}
      />
    </Stack>
  );
}
```

### Search hundreds of pages

A row's `icon` tells a page from an example. `searchedGroup` searches only “Inputs” of “core · Inputs”, so typing “core” does not find every candidate, and `maxFinds` draws the first fifty finds and says how many more there are - type “s” to see the line.

```tsx
import { useState } from "react";
import { AngleGlyph, Button, CommandPalette, GridGlyph, Stack, Text, type CommandPaletteItem } from "@umriss-ui/core";

const PAGES: Record<string, string[]> = {
  "core · Inputs": ["Button", "Input", "Textarea", "Select", "Combobox", "MultiSelect", "Checkbox", "Switch", "Slider", "NumberInput"],
  "core · Overlays": ["Modal", "Drawer", "Popover", "Tooltip", "Menu", "Toast", "CommandPalette"],
  "core · Layout": ["Stack", "Grid", "Card", "Splitter", "Tabs", "Accordion", "Divider"],
  "charts · Series": ["Line", "Area", "Bar", "Scatter", "BoxPlot", "Matrix"],
  "table · Features": ["Sorting", "Filter", "Grouping", "Selection", "Pagination", "Export"],
};
const EXAMPLES = ["A first one", "Sizes", "States", "With an error", "In a form"];

/* Every page and five examples each: some two hundred candidates. */
const ITEMS = Object.entries(PAGES).flatMap(([group, pages]) => {
  const searchedGroup = group.slice(group.indexOf(" · ") + 3);
  return pages.flatMap((page): CommandPaletteItem[] => [
    { id: page, label: page, group, searchedGroup, icon: <GridGlyph /> },
    ...EXAMPLES.map((example) => ({ id: `${page}/${example}`, label: `${page}: ${example}`, group, searchedGroup, icon: <AngleGlyph /> })),
  ]);
});

export default function SearchHundredsOfPages() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState("-");

  return (
    <Stack gap={3} align="flex-start">
      <Button onClick={() => setOpen(true)}>Search the docs</Button>
      <Text size="xs" tone="muted">
        {ITEMS.length} candidates · last opened: {last}
      </Text>
      <CommandPalette
        open={open}
        onClose={() => setOpen(false)}
        items={ITEMS}
        maxFinds={50}
        onChoose={(id) => {
          setOpen(false);
          setLast(id);
        }}
      />
    </Stack>
  );
}
```

## When to use something else

- A value for a form field, chosen from a list → Combobox
- A few actions under one button → Menu

## Keyboard

| Key | Action |
|---|---|
| `Cmd+K / Ctrl+K` | Opens the palette, with [`useCommandPaletteShortcut`](https://romanhaendler.github.io/umriss-ui/core/api/#useCommandPaletteShortcut). |
| `/` | Opens the palette too, except while typing in a text field. |
| `↓ / ↑` | Moves through the finds while the field keeps the focus. |
| `Enter` | Chooses the marked find. |
| `Escape` | Closes the palette. |

## API

### `CommandPaletteProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `open` *required* | `boolean` | — | Controlled like the modal: opening is the caller's decision.<br>Shown in: [Run a command](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#commands), [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Open it with a shortcut](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#shortcut) and 2 more |
| `items` *required* | `readonly `[`CommandPaletteItem`](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#type-CommandPaletteItem)`[]` | — | The candidates that are searched. They belong to the caller: the palette keeps no memory and does not reorder them.<br>Shown in: [Run a command](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#commands), [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Open it with a shortcut](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#shortcut) and 2 more |
| `restingItems` | `readonly `[`CommandPaletteItem`](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#type-CommandPaletteItem)`[]` | — | What stands there as long as nothing is typed. Without a value: nothing, and the window is only the field. A LIST and not a switch, because the interesting question is not "all or none" but "which". Whoever really wants to show everything passes `restingItems={items}`; whoever wants to show the five most recently used passes those - and a switch cannot do that. With long lists "all" is also exactly the state this component did away with: a list that is already full can only shrink. The rows stand in THIS order and without marks: without a query there is no rank and nothing to mark. Rank and weight therefore play no part here; the order belongs to the caller.<br>Shown in: [Run a command](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#commands), [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first) |
| `maxFinds` | `number` | every find | The most finds that stand at once - the best by rank. A last line says how many more there are, and typing on narrows them. For a long list of candidates, where a single letter finds hundreds and drawing them all would make every keystroke wait.<br>Shown in: [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `ref` | `Ref<HTMLDialogElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onClose` *required* | `() => void` | — | Reports every wish to close - Escape, an outside click, a choice.<br>Shown in: [Run a command](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#commands), [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Open it with a shortcut](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#shortcut) and 2 more |
| `onChoose` *required* | `(id: string) => void` | — | Reports the `id` of the chosen candidate. What happens next is the caller's decision - that is exactly what keeps places and commands together.<br>Shown in: [Run a command](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#commands), [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Open it with a shortcut](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#shortcut) and 2 more |

Also takes every attribute of `<dialog>` – without `children`, `onClose`, `open`.

### `CommandPaletteItem`

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` *required* | `string` | — | What is reported on choosing.<br>Shown in: [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Find a command by its synonyms](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#synonyms), [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `label` *required* | `string` | — | What is searched and displayed.<br>Shown in: [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Find a command by its synonyms](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#synonyms), [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `group` | `string` | — | The heading the candidate stands under. It is searched as well.<br>Shown in: [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first), [Find a command by its synonyms](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#synonyms), [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `searchedGroup` | `string` | the whole `group` | The part of `group` that is searched, where not all of it should be: a prefix every candidate of a set shares - `core · ` before `core · Inputs` - would otherwise let its few letters find the whole set.<br>Shown in: [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `icon` | `ReactNode` | — | Optional glyph on the left of the row.<br>Shown in: [Search hundreds of pages](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#search-hundreds-of-pages) |
| `weight` | `number` | — | Added to the rank. With it frequency or recency can be expressed - the library deliberately keeps no memory for that.<br>Shown in: [Show recent choices first](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#recent-first) |
| `keywords` | `readonly string[]` | — | More words the candidate is found by, such as synonyms - searched but never shown. Only when neither `label` nor `group` matched, only for a query of three characters or more, and as a contiguous run, not as a subsequence. A find through them stands behind every find in a label or a group and marks nothing.<br>Shown in: [Find a command by its synonyms](https://romanhaendler.github.io/umriss-ui/core/commandpalette/#synonyms) |

## Known limits

- No memory of past choices and no ranking of its own beyond the match: recency and frequency are weights you keep.
- No nested steps or arguments: a command that needs input opens your own form after it is chosen.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
