Skip to content
EMBOSS
Docs menu

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

Usage

example.tsx
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.

PropTypeDefaultDescription
headingstringThe engraved group label.

CommandItem

PropTypeDefaultDescription
valuestringThe text the item is filtered by.
keywordsstring[]Extra search terms.
onSelect() => voidRuns on Enter or click.
disabledbooleanfalseRemoves the item from filtering and selection.
Data attributeDescription
data-activePresent on the virtually highlighted option.

Keyboard

KeysAction
ArrowDownArrowUpMoves the highlight through visible options.
HomeEndJumps to the first or last option.
EnterSelects the highlighted option.

Source

View source — command.tsx
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,
};