---
name: command
type: core
domain: navigation
requires: [loke-ui]
description: >
  Command palettes and searchable lists with Command/CommandInput/CommandList/
  CommandItem/CommandGroup/CommandGroupHeading/CommandEmpty/CommandLoading.
  Built-in fuzzy filtering via commandScore. Custom filter function via filter prop.
  Compose Popover+Command for combobox/async-select patterns — the recommended
  replacement for Select when options are async, searchable, or dynamically loaded.
  Vim bindings (ctrl+n/j/p/k) enabled by default.
---

# Command

## Setup

Command palette with search input, item list, and empty state.

```tsx
import {
  Command,
  CommandInput,
  CommandList,
  CommandItem,
  CommandEmpty,
} from "@loke/ui/command";

function CommandPalette() {
  return (
    <Command label="Command palette">
      <CommandInput placeholder="Type a command..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>
        <CommandItem onSelect={() => console.log("New file")}>
          New file
        </CommandItem>
        <CommandItem onSelect={() => console.log("Open settings")}>
          Open settings
        </CommandItem>
        <CommandItem onSelect={() => console.log("Git commit")}>
          Git commit
        </CommandItem>
      </CommandList>
    </Command>
  );
}
```

## Core Patterns

### Popover + Command combobox (async select alternative)

This is the recommended pattern when `Select` does not work — specifically for async-loaded options, searchable dropdowns, or filterable option lists. `Select` assumes all items exist at open time; `Popover + Command` has no such constraint.

```tsx
import { useState } from "react";
import { Popover, PopoverTrigger, PopoverContent } from "@loke/ui/popover";
import {
  Command,
  CommandInput,
  CommandList,
  CommandItem,
  CommandEmpty,
} from "@loke/ui/command";

type Option = { value: string; label: string };

const OPTIONS: Option[] = [
  { value: "react", label: "React" },
  { value: "vue", label: "Vue" },
  { value: "svelte", label: "Svelte" },
  { value: "solid", label: "Solid" },
];

function FrameworkPicker() {
  const [open, setOpen] = useState(false);
  const [selected, setSelected] = useState<Option | null>(null);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button type="button">
          {selected ? selected.label : "Select framework..."}
        </button>
      </PopoverTrigger>
      <PopoverContent>
        <Command>
          <CommandInput placeholder="Search frameworks..." />
          <CommandList>
            <CommandEmpty>No framework found.</CommandEmpty>
            {OPTIONS.map((option) => (
              <CommandItem
                key={option.value}
                value={option.value}
                onSelect={() => {
                  setSelected(option);
                  setOpen(false);
                }}
              >
                {option.label}
              </CommandItem>
            ))}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}
```

For async options, fetch in a `useEffect` and show `CommandLoading` while pending — see the Loading state pattern below.

### Groups with headings

```tsx
import {
  Command,
  CommandInput,
  CommandList,
  CommandGroup,
  CommandGroupHeading,
  CommandItem,
  CommandSeparator,
  CommandEmpty,
} from "@loke/ui/command";

function GroupedPalette() {
  return (
    <Command label="Actions">
      <CommandInput placeholder="Search..." />
      <CommandList>
        <CommandEmpty>No results.</CommandEmpty>

        <CommandGroup>
          <CommandGroupHeading>Files</CommandGroupHeading>
          <CommandItem value="new-file" onSelect={() => {}}>New file</CommandItem>
          <CommandItem value="open-file" onSelect={() => {}}>Open file</CommandItem>
        </CommandGroup>

        <CommandSeparator />

        <CommandGroup>
          <CommandGroupHeading>Git</CommandGroupHeading>
          <CommandItem value="git-commit" onSelect={() => {}}>Commit</CommandItem>
          <CommandItem value="git-push" onSelect={() => {}}>Push</CommandItem>
        </CommandGroup>
      </CommandList>
    </Command>
  );
}
```

Groups that have no matching items are hidden automatically during filtering.

### Custom filter function

Replace the default fuzzy scoring with your own function. The filter receives the item `value`, the current `search` string, and optional `keywords`. Return a number: higher scores rank higher; `0` hides the item.

```tsx
function exactPrefixFilter(value: string, search: string): number {
  if (value.toLowerCase().startsWith(search.toLowerCase())) return 1;
  return 0;
}

<Command filter={exactPrefixFilter}>
  <CommandInput placeholder="Search..." />
  <CommandList>
    <CommandItem value="apple">Apple</CommandItem>
    <CommandItem value="apricot">Apricot</CommandItem>
    <CommandItem value="banana">Banana</CommandItem>
  </CommandList>
</Command>
```

Disable built-in filtering entirely with `shouldFilter={false}` — useful when filtering is done server-side.

### Loading state for async options

```tsx
import { useEffect, useState } from "react";
import {
  Command,
  CommandInput,
  CommandList,
  CommandItem,
  CommandEmpty,
  CommandLoading,
} from "@loke/ui/command";

function AsyncCommand() {
  const [loading, setLoading] = useState(false);
  const [items, setItems] = useState<string[]>([]);
  const [search, setSearch] = useState("");

  useEffect(() => {
    if (!search) return;
    setLoading(true);
    fetchItems(search).then((results) => {
      setItems(results);
      setLoading(false);
    });
  }, [search]);

  return (
    <Command shouldFilter={false} label="Search users">
      <CommandInput
        placeholder="Search users..."
        value={search}
        onValueChange={setSearch}
      />
      <CommandList>
        {loading && <CommandLoading>Searching...</CommandLoading>}
        {!loading && items.length === 0 && search && (
          <CommandEmpty>No users found.</CommandEmpty>
        )}
        {items.map((item) => (
          <CommandItem key={item} value={item} onSelect={() => {}}>
            {item}
          </CommandItem>
        ))}
      </CommandList>
    </Command>
  );
}
```

## Common Mistakes

### Using `Select` for async/searchable options — use Popover + Command instead

`Select` measures all `SelectItem` positions for item-aligned layout, portals `SelectItemText` into the trigger, and walks the collection for typeahead. All of these break with async-loaded or dynamically filtered options. Use the Popover + Command pattern instead.

```tsx
// Wrong — Select assumes all options exist at open time
<Select>
  <SelectTrigger>...</SelectTrigger>
  <SelectContent>
    {asyncOptions.map(opt => (
      <SelectItem key={opt.id} value={opt.id}>
        <SelectItemText>{opt.label}</SelectItemText>
      </SelectItem>
    ))}
  </SelectContent>
</Select>

// Correct — Popover + Command handles async/searchable options
<Popover open={open} onOpenChange={setOpen}>
  <PopoverTrigger asChild><button type="button">{label}</button></PopoverTrigger>
  <PopoverContent>
    <Command>
      <CommandInput onValueChange={setSearch} />
      <CommandList>
        {asyncOptions.map(opt => (
          <CommandItem key={opt.id} value={opt.id} onSelect={handleSelect}>
            {opt.label}
          </CommandItem>
        ))}
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>
```

Source: `domain_map.yaml` — `maintainer interview`; `src/components/command/command.tsx`.

### Missing `value` on `CommandItem` when children are complex — garbled filtering

`CommandItem` infers its `value` from `textContent` when no `value` prop is provided. Children containing icons, badges, or nested elements produce garbled values that cause incorrect filtering and broken selection callbacks. Always set `value` explicitly on complex items.

```tsx
// Wrong — textContent becomes "Edit✏️" or similar
<CommandItem onSelect={handleEdit}>
  <PencilIcon /> Edit
</CommandItem>

// Correct
<CommandItem value="edit" onSelect={handleEdit}>
  <PencilIcon /> Edit
</CommandItem>
```

Source: `src/components/command/command.tsx` — `useValue` hook extracts from `ref.current?.textContent`.

### Forgetting `CommandList` wrapper — missing ARIA semantics and broken layout

`CommandList` provides the `listbox` role, accessible label (`aria-label`), and sets the `--loke-cmd-list-height` CSS variable. Items rendered outside `CommandList` lack proper ARIA structure and the height measurement used for animated list resizing.

```tsx
// Wrong
<Command>
  <CommandInput placeholder="Search..." />
  <CommandItem value="a">Item A</CommandItem>
  <CommandItem value="b">Item B</CommandItem>
</Command>

// Correct
<Command>
  <CommandInput placeholder="Search..." />
  <CommandList>
    <CommandItem value="a">Item A</CommandItem>
    <CommandItem value="b">Item B</CommandItem>
  </CommandList>
</Command>
```

Source: `src/components/command/command.tsx` — `CommandList` sets `role="listbox"`.

## Cross-references

- **Popover** (`@loke/ui/popover`) — required for the Popover + Command combobox pattern
- **Select** (`@loke/ui/select`) — use for static, non-searchable option lists
- **Choosing the Right Component** — Select vs Popover+Command decision guidance
- **Component reference** — [`references/command-components.md`](references/command-components.md)
