Command
A filtering command list with combobox semantics — the virtual highlight walks visible options while focus stays in the input.
@emboss/command
Nothing on the bench matches.
Installation
pnpm dlx shadcn@latest add @emboss/commandUsage
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
export function Example() {
return (
<Command>
<CommandInput placeholder="Search…" aria-label="Search commands" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
<CommandGroup heading="Edit">
<CommandItem value="Copy">Copy</CommandItem>
<CommandItem value="Paste">Paste</CommandItem>
</CommandGroup>
</CommandList>
</Command>
);
}API reference
Command / CommandInput / CommandList / CommandEmpty
The filtering container, its combobox input, the listbox, and the no-results state.
CommandGroup
A labelled group that hides itself when none of its items match.
| Prop | Type | Default | Description |
|---|---|---|---|
| heading | string | — | The engraved group label. |
CommandItem
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The text the item is filtered by. |
| keywords | string[] | — | Extra search terms. |
| onSelect | () => void | — | Runs on Enter or click. |
| disabled | boolean | false | Removes the item from filtering and selection. |
| Data attribute | Description |
|---|---|
| data-active | Present on the virtually highlighted option. |
Keyboard
| Keys | Action |
|---|---|
| ArrowDownArrowUp | Moves the highlight through visible options. |
| HomeEnd | Jumps to the first or last option. |
| Enter | Selects the highlighted option. |
Source
View source — command.tsxHide source — command.tsx
"use client";
import { Search } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
interface CommandItemRecord {
id: string;
value: string;
keywords: string[];
disabled: boolean;
groupId: string | null;
onSelect: (() => void) | undefined;
}
interface CommandContextValue {
query: string;
setQuery: (query: string) => void;
register: (item: CommandItemRecord) => () => void;
matchedIds: string[];
matchedGroups: Set<string>;
activeId: string | null;
setActiveId: (id: string | null) => void;
selectActive: () => void;
moveActive: (delta: number | "start" | "end") => void;
listId: string;
}
const CommandContext = React.createContext<CommandContextValue | null>(null);
const GroupContext = React.createContext<string | null>(null);
function useCommandContext(part: string): CommandContextValue {
const context = React.useContext(CommandContext);
if (!context) {
throw new Error(`${part} must be used inside <Command>.`);
}
return context;
}
function matches(item: CommandItemRecord, query: string): boolean {
if (!query) return true;
const haystack = [item.value, ...item.keywords].join(" ").toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((word) => haystack.includes(word));
}
/**
* A filtering command list with combobox semantics: focus stays in the
* input while arrow keys move a virtual highlight through the visible
* options. The site's ⌘K search is built from exactly this.
*
* @example
* <Command>
* <CommandInput placeholder="Search…" aria-label="Search commands" />
* <CommandList>
* <CommandEmpty>No results.</CommandEmpty>
* <CommandItem value="Copy" onSelect={copy}>Copy</CommandItem>
* </CommandList>
* </Command>
*/
function Command({
className,
children,
...props
}: React.ComponentProps<"div">) {
const listId = React.useId();
const [query, setQuery] = React.useState("");
const [items, setItems] = React.useState<Map<string, CommandItemRecord>>(
() => new Map(),
);
const [intendedActiveId, setActiveId] = React.useState<string | null>(null);
const register = React.useCallback((item: CommandItemRecord) => {
setItems((current) => {
const next = new Map(current);
next.set(item.id, item);
return next;
});
return () => {
setItems((current) => {
const next = new Map(current);
next.delete(item.id);
return next;
});
};
}, []);
const { matchedIds, matchedGroups } = React.useMemo(() => {
const ids: string[] = [];
const groups = new Set<string>();
for (const item of items.values()) {
if (item.disabled || !matches(item, query)) continue;
ids.push(item.id);
if (item.groupId) groups.add(item.groupId);
}
return { matchedIds: ids, matchedGroups: groups };
}, [items, query]);
// The effective highlight is derived: the intended id while it remains
// visible, otherwise the first visible option.
const activeId =
intendedActiveId && matchedIds.includes(intendedActiveId)
? intendedActiveId
: (matchedIds[0] ?? null);
const moveActive = React.useCallback(
(delta: number | "start" | "end") => {
setActiveId((intended) => {
if (matchedIds.length === 0) return null;
const current =
intended && matchedIds.includes(intended)
? intended
: (matchedIds[0] ?? null);
if (delta === "start") return matchedIds[0] ?? null;
if (delta === "end") return matchedIds.at(-1) ?? null;
const index = current ? matchedIds.indexOf(current) : -1;
const next = Math.min(
Math.max(index + delta, 0),
matchedIds.length - 1,
);
return matchedIds[next] ?? null;
});
},
[matchedIds],
);
const selectActive = React.useCallback(() => {
if (!activeId) return;
items.get(activeId)?.onSelect?.();
}, [activeId, items]);
const context = React.useMemo(
() => ({
query,
setQuery,
register,
matchedIds,
matchedGroups,
activeId,
setActiveId,
selectActive,
moveActive,
listId,
}),
[
query,
register,
matchedIds,
matchedGroups,
activeId,
selectActive,
moveActive,
listId,
],
);
return (
<CommandContext.Provider value={context}>
<div
data-slot="command"
className={cn(
"flex w-full flex-col overflow-hidden rounded-lg bg-surface-2 text-ink",
className,
)}
{...props}
>
{children}
</div>
</CommandContext.Provider>
);
}
function CommandInput({
className,
...props
}: Omit<React.ComponentProps<"input">, "value" | "onChange">) {
const context = useCommandContext("CommandInput");
return (
<div className="flex items-center gap-2 border-b border-edge-line px-3">
<Search className="size-4 shrink-0 text-ink-faint" aria-hidden />
<input
data-slot="command-input"
role="combobox"
aria-expanded="true"
aria-controls={context.listId}
aria-activedescendant={context.activeId ?? undefined}
autoComplete="off"
spellCheck={false}
value={context.query}
onChange={(event) => {
context.setQuery(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
context.moveActive(1);
break;
case "ArrowUp":
event.preventDefault();
context.moveActive(-1);
break;
case "Home":
event.preventDefault();
context.moveActive("start");
break;
case "End":
event.preventDefault();
context.moveActive("end");
break;
case "Enter":
event.preventDefault();
context.selectActive();
break;
default:
break;
}
}}
className={cn(
"h-11 w-full bg-transparent text-sm outline-none placeholder:text-ink-faint",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<"div">) {
const context = useCommandContext("CommandList");
return (
<div
data-slot="command-list"
id={context.listId}
role="listbox"
className={cn("max-h-72 overflow-y-auto p-1", className)}
{...props}
/>
);
}
function CommandEmpty({ className, ...props }: React.ComponentProps<"div">) {
const context = useCommandContext("CommandEmpty");
if (context.matchedIds.length > 0) return null;
return (
<div
data-slot="command-empty"
className={cn("py-6 text-center text-sm text-ink-muted", className)}
{...props}
/>
);
}
function CommandGroup({
className,
heading,
children,
...props
}: React.ComponentProps<"div"> & { heading: string }) {
const context = useCommandContext("CommandGroup");
const groupId = React.useId();
const visible = context.matchedGroups.has(groupId);
return (
<GroupContext.Provider value={groupId}>
<div
data-slot="command-group"
role="group"
aria-label={heading}
hidden={!visible}
className={className}
{...props}
>
<p
aria-hidden
className="tracking-label px-2 py-1.5 font-mono text-label text-ink-faint uppercase text-engraved"
>
{heading}
</p>
{children}
</div>
</GroupContext.Provider>
);
}
function CommandItem({
className,
value,
keywords,
disabled = false,
onSelect,
children,
...props
}: Omit<React.ComponentProps<"div">, "onSelect"> & {
/** The text the item is filtered by. */
value: string;
/** Extra search terms. */
keywords?: string[];
disabled?: boolean;
onSelect?: () => void;
}) {
const context = useCommandContext("CommandItem");
const { register, setActiveId } = context;
const groupId = React.useContext(GroupContext);
const id = React.useId();
const keywordsKey = (keywords ?? []).join(" ");
// The registration outlives individual renders; reading the handler
// through a ref keeps Enter-to-select fresh without re-registering.
const onSelectRef = React.useRef(onSelect);
React.useEffect(() => {
onSelectRef.current = onSelect;
});
React.useEffect(() => {
return register({
id,
value,
keywords: keywordsKey ? keywordsKey.split(" ") : [],
disabled,
groupId,
onSelect: () => onSelectRef.current?.(),
});
}, [register, id, value, keywordsKey, disabled, groupId]);
const visible = context.matchedIds.includes(id);
if (!visible) return null;
const active = context.activeId === id;
return (
// Keyboard interaction lives on the combobox input per the
// aria-activedescendant pattern; the option itself is a pointer target.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
data-slot="command-item"
id={id}
role="option"
tabIndex={-1}
aria-selected={active}
data-active={active ? "" : undefined}
onPointerMove={() => {
if (!active) setActiveId(id);
}}
onClick={() => {
onSelect?.();
}}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm select-none data-active:bg-well data-active:shadow-deboss-1",
className,
)}
{...props}
>
{children}
</div>
);
}
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
};