Command
A slash-command list that opens above an input when the user types a trigger like "/" or "、", or presses a trigger button.
Nothing selected yet.
Unlike a command palette dialog, Command lives inside the input the user is already typing in. It is built on Base UI Autocomplete, so filtering, keyboard navigation, and ARIA wiring come for free. Wrap an InputGroup with Command and replace the control with CommandInput, which renders InputGroupInput (or InputGroupTextarea with multiline) by default.
- Typing a trigger (
/or、by default, so Chinese IMEs work too) at the start of the input opens the list; the text after it filters the commands. - ↑ ↓ move the highlight, Enter selects (set
confirmKey="tab"or"both"to confirm with Tab), Esc closes. When the list is closed these keys keep their native behaviour, so the user's draft is never touched. - Selecting a command removes the trigger segment from the input and calls
onSelect.
Examples
Agent chat
A multiline chat composer with grouped commands and file references. Type / at the start for commands, or @ at the start or after whitespace to search the example files by name or path. Selecting a file inserts @path at the caret and preserves the surrounding text. Press Enter to submit; with confirmKey="both", Enter or Tab selects the highlighted item instead. Items in the Skills group keep their name in the input (/create-pr ) so the user can add arguments and file references before sending.
With trigger
Render CommandTrigger as an InputGroupButton to open the list with the pointer. When the input is empty, pressing it inserts the trigger character so the user can keep typing to filter.
Custom trigger
Pass a matcher function as trigger to decide which input opens the list. createCommandTrigger("@", { position: "anywhere" }) matches a mention anywhere before the caret; onSelect receives the matched segment so you can insert the result back into the value.
Installation
Copy the source code below into your project:
import * as React from "react";
import { Autocomplete as BaseAutocomplete } from "@base-ui/react/autocomplete";
import { cn } from "@/lib/utils";
import { InputGroupInput, InputGroupTextarea } from "./input-group";
import { ScrollArea } from "./scroll-area";
interface CommandTriggerMatch {
query: string;
start: number;
end: number;
}
type CommandTriggerMatcher = (value: string, caret: number) => CommandTriggerMatch | null;
interface CreateCommandTriggerOptions {
position?: "start" | "anywhere";
}
const DEFAULT_COMMAND_TRIGGERS: readonly string[] = ["/", "、"];
function isGroupedItems(
items: readonly unknown[],
): items is readonly { items: readonly unknown[] }[] {
const first = items[0];
return (
typeof first === "object" && first !== null && "items" in first && Array.isArray(first.items)
);
}
function createCommandTrigger(
trigger: string | readonly string[],
{ position = "start" }: CreateCommandTriggerOptions = {},
): CommandTriggerMatcher {
const triggers = typeof trigger === "string" ? [trigger] : trigger;
return (value, caret) => {
if (position === "start") {
const prefix = triggers.find((candidate) => value.startsWith(candidate));
if (prefix === undefined) return null;
const query = value.slice(prefix.length);
if (/\s/.test(query)) return null;
return { end: value.length, query, start: 0 };
}
const before = value.slice(0, caret);
let start = -1;
let matched = "";
for (const candidate of triggers) {
const index = before.lastIndexOf(candidate);
if (index > start) {
start = index;
matched = candidate;
}
}
if (start === -1) return null;
if (start > 0 && !/\s/.test(before[start - 1] ?? "")) return null;
const query = before.slice(start + matched.length);
if (/\s/.test(query)) return null;
return { end: caret, query, start };
};
}
type CommandConfirmKey = "enter" | "tab" | "both";
interface CommandContextValue {
open: boolean;
query: string;
confirmKey: CommandConfirmKey;
inputRef: React.RefObject<HTMLInputElement | null>;
highlightedRef: React.RefObject<unknown>;
getAnchor: () => Element | null;
stageSelection: (itemValue: unknown) => void;
}
const CommandContext = React.createContext<CommandContextValue | null>(null);
function useCommandContext() {
const context = React.useContext(CommandContext);
if (!context) {
throw new Error("Command components must be used within <Command>.");
}
return context;
}
function useCommand() {
const { open, query } = useCommandContext();
return { open, query: open ? query : "" };
}
type CommandChangeEventDetails = BaseAutocomplete.Root.ChangeEventDetails;
interface CommandSelectDetails {
match: CommandTriggerMatch | null;
query: string;
value: string;
}
type CommandFilter<ItemValue> = (
itemValue: ItemValue,
query: string,
itemToStringValue?: (itemValue: ItemValue) => string,
) => boolean;
interface CommandProps<ItemValue> extends Omit<
BaseAutocomplete.Root.Props<ItemValue>,
| "mode"
| "inline"
| "open"
| "defaultOpen"
| "openOnInputClick"
| "filter"
| "value"
| "defaultValue"
| "onValueChange"
| "filteredItems"
| "limit"
> {
trigger?: string | readonly string[] | CommandTriggerMatcher;
filter?: CommandFilter<ItemValue>;
value?: string;
defaultValue?: string;
onValueChange?: (value: string, eventDetails: CommandChangeEventDetails) => void;
onSelect?: (itemValue: ItemValue, details: CommandSelectDetails) => void;
confirmKey?: CommandConfirmKey;
}
function Command<Items extends readonly { items: readonly any[] }[]>(
props: Omit<CommandProps<Items[number]["items"][number]>, "items"> & { items: Items },
): React.JSX.Element;
function Command<ItemValue>(
props: Omit<CommandProps<ItemValue>, "items"> & { items?: readonly ItemValue[] | undefined },
): React.JSX.Element;
function Command({
trigger = DEFAULT_COMMAND_TRIGGERS,
filter,
value: valueProp,
defaultValue,
onValueChange,
onOpenChange,
onSelect,
onItemHighlighted,
confirmKey = "enter",
autoHighlight = "always",
keepHighlight = true,
itemToStringValue,
...props
}: CommandProps<any>) {
const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue ?? "");
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : uncontrolledValue;
const [open, setOpen] = React.useState(false);
const [match, setMatch] = React.useState<CommandTriggerMatch | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const highlightedRef = React.useRef<unknown>(undefined);
const pendingSelectionRef = React.useRef<{ itemValue: unknown } | null>(null);
const triggers =
typeof trigger === "function" ? null : typeof trigger === "string" ? [trigger] : trigger;
const matcher = React.useMemo(
() => (typeof trigger === "function" ? trigger : createCommandTrigger(trigger)),
[trigger],
);
const collator = BaseAutocomplete.useFilter({ locale: props.locale });
const query = match?.query ?? "";
const filteredItems = React.useMemo(() => {
const { items } = props;
if (!items || query === "") return items;
const matches = (itemValue: unknown) =>
(filter ?? collator.contains)(itemValue, query, itemToStringValue);
return isGroupedItems(items)
? items
.map((group) => ({ ...group, items: group.items.filter(matches) }))
.filter((group) => group.items.length > 0)
: items.filter(matches);
}, [props.items, filter, collator, query, itemToStringValue]);
const commitValue = (next: string, eventDetails: CommandChangeEventDetails) => {
if (!isControlled) setUncontrolledValue(next);
onValueChange?.(next, eventDetails);
};
const commitOpen = (next: boolean, eventDetails: CommandChangeEventDetails) => {
if (next === open) return;
setOpen(next);
onOpenChange?.(next, eventDetails);
};
const getCaret = (fallback: string) => inputRef.current?.selectionStart ?? fallback.length;
const completeSelection = (eventDetails: CommandChangeEventDetails) => {
const pending = pendingSelectionRef.current;
if (!pending) return;
pendingSelectionRef.current = null;
const nextValue = match ? value.slice(0, match.start) + value.slice(match.end) : value;
commitValue(nextValue, eventDetails);
commitOpen(false, eventDetails);
onSelect?.(pending.itemValue, { match, query: match?.query ?? "", value: nextValue });
};
const handleValueChange = (next: string, eventDetails: CommandChangeEventDetails) => {
const { reason } = eventDetails;
if (reason === "item-press") {
completeSelection(eventDetails);
return;
}
commitValue(next, eventDetails);
const nextMatch = matcher(next, getCaret(next));
if (nextMatch) {
setMatch(nextMatch);
commitOpen(true, eventDetails);
} else {
commitOpen(false, eventDetails);
}
};
const handleOpenChange = (next: boolean, eventDetails: CommandChangeEventDetails) => {
const { reason } = eventDetails;
if (!next) {
commitOpen(false, eventDetails);
return;
}
if (reason === "input-change" || reason === "input-press") return;
const insert = triggers?.[0];
if (insert && value === "") {
commitValue(insert, eventDetails);
setMatch({ end: insert.length, query: "", start: 0 });
} else {
setMatch(matcher(value, getCaret(value)));
}
commitOpen(true, eventDetails);
};
const contextValue = React.useMemo<CommandContextValue>(
() => ({
getAnchor: () =>
inputRef.current?.closest("[data-slot=input-group]") ?? inputRef.current ?? null,
confirmKey,
highlightedRef,
inputRef,
open,
query,
stageSelection: (itemValue) => {
pendingSelectionRef.current = { itemValue };
},
}),
[confirmKey, open, query],
);
return (
<CommandContext.Provider value={contextValue}>
<BaseAutocomplete.Root
{...props}
autoHighlight={autoHighlight}
filteredItems={filteredItems}
itemToStringValue={itemToStringValue}
keepHighlight={keepHighlight}
mode="list"
onItemHighlighted={(highlightedValue, eventDetails) => {
highlightedRef.current = highlightedValue;
onItemHighlighted?.(highlightedValue, eventDetails);
}}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
open={open}
openOnInputClick={false}
value={value}
/>
</CommandContext.Provider>
);
}
const PASSTHROUGH_KEYS_WHEN_CLOSED = new Set(["ArrowUp", "ArrowDown", "Home", "End", "Escape"]);
interface CommandInputProps extends BaseAutocomplete.Input.Props {
multiline?: boolean;
}
function CommandInput({ multiline = false, onKeyDown, ref, render, ...props }: CommandInputProps) {
const { open, confirmKey, highlightedRef, inputRef } = useCommandContext();
return (
<BaseAutocomplete.Input
ref={(element: HTMLInputElement | null) => {
inputRef.current = element;
if (typeof ref === "function") return ref(element);
if (ref) ref.current = element;
}}
onKeyDown={(event) => {
if (!open) {
if (PASSTHROUGH_KEYS_WHEN_CLOSED.has(event.key)) event.preventBaseUIHandler();
onKeyDown?.(event);
return;
}
if (event.key === "Home" || event.key === "End") event.preventBaseUIHandler();
const hasModifier = event.shiftKey || event.metaKey || event.ctrlKey || event.altKey;
const canConfirm = highlightedRef.current !== undefined && !hasModifier;
const isEnter = event.key === "Enter" && confirmKey !== "tab";
const isTab = event.key === "Tab" && confirmKey !== "enter";
if (event.key === "Enter" && !isEnter) event.preventBaseUIHandler();
if (canConfirm && isTab) {
// Base UI only confirms on Enter, so click the highlighted item ourselves.
event.preventDefault();
const activeId = event.currentTarget.getAttribute("aria-activedescendant");
if (activeId) document.getElementById(activeId)?.click();
return;
}
if (!(canConfirm && isEnter)) onKeyDown?.(event);
}}
render={render ?? (multiline ? <InputGroupTextarea /> : <InputGroupInput />)}
{...props}
/>
);
}
function CommandTrigger({ className, ...props }: BaseAutocomplete.Trigger.Props) {
return (
<BaseAutocomplete.Trigger
className={cn("rounded-sm", className)}
data-slot="command-trigger"
{...props}
/>
);
}
function CommandContent({
className,
positionerProps,
children,
...props
}: BaseAutocomplete.Popup.Props & {
positionerProps?: BaseAutocomplete.Positioner.Props;
}) {
const { getAnchor } = useCommandContext();
return (
<BaseAutocomplete.Portal>
<BaseAutocomplete.Positioner
align="start"
anchor={getAnchor}
side="top"
sideOffset={8}
{...positionerProps}
className={cn(
"z-50 w-(--anchor-width) outline-none select-none",
positionerProps?.className,
)}
>
<BaseAutocomplete.Popup
className={cn(
"group flex max-h-[min(var(--available-height),20rem)] origin-bottom flex-col overflow-hidden rounded-xl bg-popover text-popover-foreground shadow-lg outline outline-border transition-[transform,scale,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 data-[side=bottom]:origin-top dark:shadow-none",
className,
)}
data-slot="command-popup"
{...props}
>
<ScrollArea
className="flex min-h-0 flex-1 flex-col"
viewportProps={{ className: "min-h-0 flex-1 p-1.5" }}
scrollBarProps={{ className: "my-2" }}
>
{children}
</ScrollArea>
</BaseAutocomplete.Popup>
</BaseAutocomplete.Positioner>
</BaseAutocomplete.Portal>
);
}
function CommandList({ className, children, ...props }: BaseAutocomplete.List.Props) {
return (
<BaseAutocomplete.List
className={cn("flex flex-col", className)}
data-slot="command-list"
{...props}
>
{children}
</BaseAutocomplete.List>
);
}
function CommandItem({ className, children, onClick, ...props }: BaseAutocomplete.Item.Props) {
const { stageSelection } = useCommandContext();
return (
<BaseAutocomplete.Item
className={cn(
"group flex cursor-default items-center gap-2 rounded-lg p-2 text-sm leading-4 outline-none select-none data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 data-highlighted:bg-primary data-highlighted:text-primary-foreground pointer-coarse:py-2.5 pointer-coarse:text-[0.925rem]",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-highlighted:[&_svg:not([class*='text-'])]:text-primary-foreground",
className,
)}
data-slot="command-item"
onClick={(event) => {
onClick?.(event);
stageSelection(props.value);
}}
{...props}
>
{children}
</BaseAutocomplete.Item>
);
}
function CommandItemLabel({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn("truncate font-medium", className)}
data-slot="command-item-label"
{...props}
/>
);
}
function CommandItemDescription({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"truncate text-muted-foreground group-data-highlighted:text-primary-foreground/70",
className,
)}
data-slot="command-item-description"
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"ml-auto flex shrink-0 items-center gap-1 text-xs text-muted-foreground group-data-highlighted:text-primary-foreground/70",
className,
)}
data-slot="command-shortcut"
{...props}
/>
);
}
function CommandEmpty({ className, children, ...props }: BaseAutocomplete.Empty.Props) {
return (
<BaseAutocomplete.Empty
className={cn("px-2.5 py-2 text-sm text-muted-foreground empty:m-0 empty:p-0", className)}
data-slot="command-empty"
{...props}
>
{children}
</BaseAutocomplete.Empty>
);
}
function CommandStatus({ className, children, ...props }: BaseAutocomplete.Status.Props) {
return (
<BaseAutocomplete.Status
className={cn("px-2.5 py-2 text-sm text-muted-foreground empty:m-0 empty:p-0", className)}
data-slot="command-status"
{...props}
>
{children}
</BaseAutocomplete.Status>
);
}
function CommandSeparator({ className, ...props }: BaseAutocomplete.Separator.Props) {
return (
<BaseAutocomplete.Separator
className={cn("-mx-1.5 my-1.5 h-px bg-border", className)}
data-slot="command-separator"
{...props}
/>
);
}
function CommandGroup({ className, ...props }: BaseAutocomplete.Group.Props) {
return (
<BaseAutocomplete.Group
className={cn("flex flex-col", className)}
data-slot="command-group"
{...props}
/>
);
}
function CommandGroupLabel({ className, ...props }: BaseAutocomplete.GroupLabel.Props) {
return (
<BaseAutocomplete.GroupLabel
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground select-none", className)}
data-slot="command-group-label"
{...props}
/>
);
}
const CommandCollection = BaseAutocomplete.Collection;
const CommandRow = BaseAutocomplete.Row;
export {
Command,
CommandInput,
CommandTrigger,
CommandContent,
CommandList,
CommandItem,
CommandItemLabel,
CommandItemDescription,
CommandShortcut,
CommandEmpty,
CommandStatus,
CommandSeparator,
CommandGroup,
CommandGroupLabel,
CommandCollection,
CommandRow,
createCommandTrigger,
useCommand,
};
export type {
CommandConfirmKey,
CommandProps,
CommandInputProps,
CommandFilter,
CommandSelectDetails,
CommandTriggerMatch,
CommandTriggerMatcher,
CreateCommandTriggerOptions,
};API Reference
Command
The Command component extends the Base UI Autocomplete.Root props and adds the following:
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | string | string[] | CommandTriggerMatcher | ["/", "、"] | What opens the list while typing. A string or array of strings opens it when the value starts with one of them; a matcher function returns the matched segment or null. See createCommandTrigger. |
| filter | (itemValue, query, itemToStringValue?) => boolean | - | Matches an item against the query typed after the trigger. Defaults to a locale-aware contains match. |
| value | string | - | The input value. Use when controlled. |
| defaultValue | string | - | The uncontrolled input value when initially rendered. |
| onValueChange | (value: string, eventDetails) => void | - | Called when the input value changes. |
| onSelect | (itemValue, details: { match, query, value }) => void | - | Called when a command is selected with the pointer or the confirm key. The trigger segment is removed from the input before this fires; details.value is the resulting input value. |
| confirmKey | "enter" | "tab" | "both" | "enter" | Which key confirms the highlighted command. |
| autoHighlight | boolean | "always" | "always" | Whether the first matching item is highlighted automatically. |
CommandInput
The CommandInput component extends the Base UI Autocomplete.Input props and adds the following:
| Prop | Type | Default | Description |
|---|---|---|---|
| multiline | boolean | false | Render an InputGroupTextarea instead of an InputGroupInput. |
| render | ReactElement | - | Custom element to render as the input. Defaults to InputGroupInput, or InputGroupTextarea when multiline is set. |
CommandTrigger
This component does not add any props on top of Base UI Autocomplete.Trigger. See the Base UI docs for the full API reference.
CommandContent
The CommandContent component extends the Base UI Autocomplete.Popup props and adds the following:
| Prop | Type | Default | Description |
|---|---|---|---|
| positionerProps | BaseAutocomplete.Positioner.Props | - | Props forwarded to the underlying Positioner. Defaults to side top, align start, anchored to the closest InputGroup. |
CommandList
This component does not add any props on top of Base UI Autocomplete.List. See the Base UI docs for the full API reference.
CommandItem
This component does not add any props on top of Base UI Autocomplete.Item. See the Base UI docs for the full API reference.
CommandItemLabel
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional classes for the label. |
CommandItemDescription
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional classes for the muted description shown after the label. |
CommandShortcut
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional classes for the trailing shortcut slot. Place Kbd elements inside. |
CommandEmpty
This component does not add any props on top of Base UI Autocomplete.Empty. See the Base UI docs for the full API reference.
CommandStatus
This component does not add any props on top of Base UI Autocomplete.Status. See the Base UI docs for the full API reference.
CommandSeparator
This component does not add any props on top of Base UI Autocomplete.Separator. See the Base UI docs for the full API reference.
CommandGroup
This component does not add any props on top of Base UI Autocomplete.Group. See the Base UI docs for the full API reference.
CommandGroupLabel
This component does not add any props on top of Base UI Autocomplete.GroupLabel. See the Base UI docs for the full API reference.
createCommandTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| trigger | string | string[] | - | The character(s) that open the list. |
| options.position | "start" | "anywhere" | "start" | start matches only when the whole value starts with the trigger; anywhere matches the trigger after whitespace before the caret. |
useCommand
Returns { open, query } for the closest Command, useful for showing hints or disabling other shortcuts while the list is open.