Skip to content
Monogem

Component · Composite & Advanced

Combobox / Command

Overview

Command is the filterable-list primitive: a text box (role="combobox") over a role="listbox" of items, with case-insensitive substring filtering and a single active row tracked by aria-activedescendant. It is the "Select with a search field" for lists too long for a plain Select.

Command provides live text filtering, aria-activedescendant on an editable input, and one surface that two different container patterns reuse — see Accessibility.

It is not a menu of commands you navigate with the arrow keys from a button — that is Dropdown Menu (role="menu", roving DOM focus, no text filter). It is not a value picker with a fixed short list — that is Select. Command earns its place only when the list is long enough that the user needs to type to narrow it.

Anatomy

Command                      (state: filter text, ACTIVE item id, has-results)
├─ CommandInput              <input role="combobox">, leading Search glyph, --border underline
└─ CommandList               role="listbox", scrolls
   ├─ CommandEmpty           shown only while the filter matches nothing
   ├─ CommandGroup           role="group" (aria-labelledby → its heading); hidden when it has no matches
   │  ├─ CommandItem         role="option", aria-selected when active
   │  └─ CommandItem  …
   ├─ CommandSeparator       role="separator"; hidden while filtering
   └─ CommandItem       …

CommandDialog                <Dialog> + <DialogContent> + a visually-hidden <DialogTitle>, wrapping a <Command>
  • Command — the client root. Holds the filter text (uncontrolled, or controlled with value + onValueChange), the active item id, and whether anything currently matches. filter overrides the built-in predicate; shouldFilter={false} turns filtering off entirely so you can render an already-narrowed list (async / remote search).
  • CommandInput — the search box, role="combobox" with aria-controls → the list, aria-activedescendant → the active item, and aria-autocomplete="list". It takes DOM focus on mount (so a pointer-opened Popover lands here) and keeps it the whole time — nothing in the list is a tab stop.
  • CommandList — the role="listbox" scroll region. Give it an aria-label so the list is named.
  • CommandEmpty — your "no results" copy. Renders only while hasResults is false; it is an aria-live="polite" region so the change is announced.
  • CommandItem — one row. role="option", aria-selected while it is the active row. value (required) is what the filter matches and what onSelect(value) receives; keywords adds extra match terms that aren't shown. disabled marks a row unavailable — aria-disabled="true", opacity-50; it still appears in the list when it matches the query, but the arrow keys skip it and it is never the active row (Enter / click do nothing). A non-matching row gets the hidden attribute (out of the layout and the accessibility tree).
  • CommandGroup — a labelled cluster (heading prop). The whole group, heading included, gets hidden only when filtering has removed every rendered row inside it — a group still showing a disabled (but matching) row stays visible, consistent with hasResults.
  • CommandSeparator — a hairline between groups; hidden whenever the filter is non-empty (dividers between filtered fragments read as noise).
  • CommandDialog — the palette container. open / onOpenChange are caller-owned; title is the dialog's accessible name, rendered visually hidden.

Live Example

Combobox — Popover + Command

Nothing committed yet.

Command palette — Command + Dialog (⌘K / Ctrl+K)

No command run yet.

Code Example

command-showcase.tsx
"use client";

import * as React from "react";
import {
Calculator,
Check,
ChevronsUpDown,
CreditCard,
FilePlus,
Settings,
Smile,
User,
} from "lucide-react";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";

/**
* Live Combobox / Command example — the filterable-list primitive in its two
* documented compositions.
*
* - Combobox: <Command> inside a <Popover>, opened by an Input-styled
* disclosure button, committing one value. DOM focus moves into the
* <CommandInput> on open and back to the button on commit / dismiss. Arrow
* keys move the active row and do NOT wrap; substring filtering hides
* non-matching rows from the layout and the a11y tree. The "Prefix match
* only" checkbox swaps the `filter` prop at runtime — the list, empty
* state, and active row all re-reconcile against the new predicate.
* - Command palette: <CommandDialog> composes the public Dialog API, so it
* inherits the real focus trap and focus restore. The ⌘K / Ctrl+K shortcut
* is wired HERE, by the caller — the component ships no global key listener.
*/

const FRAMEWORKS = [
{ value: "next", label: "Next.js" },
{ value: "remix", label: "Remix" },
{ value: "astro", label: "Astro" },
{ value: "nuxt", label: "Nuxt" },
{ value: "svelte", label: "SvelteKit" },
{ value: "solid", label: "SolidStart" },
];

const TRIGGER_CLASS =
"flex h-10 w-64 items-center justify-between gap-2 rounded-sm border-[length:var(--scale-px)] border-solid border-input bg-background px-3 text-sm leading-normal text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";

/** Match only when the label starts with the query — a runtime `filter` swap. */
function prefixFilter(value: string, search: string): boolean {
return value.toLowerCase().startsWith(search.trim().toLowerCase());
}

export function CommandShowcase() {
const [comboOpen, setComboOpen] = React.useState(false);
const [framework, setFramework] = React.useState<string>("");
const [prefixOnly, setPrefixOnly] = React.useState(false);
const comboTriggerRef = React.useRef<HTMLButtonElement>(null);

const [paletteOpen, setPaletteOpen] = React.useState(false);
const [lastAction, setLastAction] = React.useState<string | null>(null);

React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
setPaletteOpen((open) => !open);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);

function runCommand(name: string) {
setLastAction(name);
setPaletteOpen(false);
}

const selectedLabel = FRAMEWORKS.find((f) => f.value === framework)?.label;

return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-1.5">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Combobox — Popover + Command
</h3>
<Label htmlFor="framework-trigger">Framework</Label>
<Popover open={comboOpen} onOpenChange={setComboOpen}>
<PopoverTrigger>
<button
ref={comboTriggerRef}
id="framework-trigger"
type="button"
className={cn(TRIGGER_CLASS, !selectedLabel && "text-muted-foreground")}
>
{selectedLabel ?? "Select framework…"}
<ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent aria-label="Search frameworks" className="w-64 p-0">
<Command filter={prefixOnly ? prefixFilter : undefined}>
<CommandInput placeholder="Search framework…" aria-label="Search framework" />
<CommandList aria-label="Frameworks">
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{FRAMEWORKS.map((f) => (
<CommandItem
key={f.value}
value={f.label}
onSelect={() => {
setFramework(f.value === framework ? "" : f.value);
setComboOpen(false);
comboTriggerRef.current?.focus();
}}
>
<Check
aria-hidden="true"
className={cn(
"size-4",
f.value === framework ? "opacity-100" : "opacity-0",
)}
/>
{f.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<label className="flex w-fit items-center gap-2 text-sm leading-normal text-muted-foreground">
<Checkbox
checked={prefixOnly}
onChange={(event) => setPrefixOnly(event.target.checked)}
/>
Prefix match only
</label>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{selectedLabel ? `Committed: ${selectedLabel}` : "Nothing committed yet."}
</p>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Command palette — Command + Dialog (⌘K / Ctrl+K)
</h3>
<Button variant="outline" onClick={() => setPaletteOpen(true)}>
Open command palette
</Button>
<CommandDialog
open={paletteOpen}
onOpenChange={setPaletteOpen}
title="Command palette"
>
<CommandInput
placeholder="Type a command or search…"
aria-label="Command palette"
/>
<CommandList aria-label="Commands">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Suggestions">
<CommandItem
value="New file"
keywords={["create", "add", "document"]}
onSelect={runCommand}
>
<FilePlus />
New file
</CommandItem>
<CommandItem
value="Calculator"
keywords={["math", "compute"]}
onSelect={runCommand}
>
<Calculator />
Calculator
</CommandItem>
<CommandItem
value="Emoji picker"
keywords={["smiley", "react"]}
onSelect={runCommand}
>
<Smile />
Emoji picker
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem value="Profile" onSelect={runCommand}>
<User />
Profile
</CommandItem>
<CommandItem value="Preferences" onSelect={runCommand}>
<Settings />
Preferences
</CommandItem>
<CommandItem value="Billing" disabled onSelect={runCommand}>
<CreditCard />
Billing — unavailable on this plan
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
<p className="text-sm leading-normal text-muted-foreground" aria-live="polite">
{lastAction ? `Ran: ${lastAction}` : "No command run yet."}
</p>
</section>
</div>
);
}

Variants

None. One surface style, one input style, one row style. The only expressive room is per-item: a leading icon as the row's first child, and className for anything bespoke. Width is caller-controlled — the Combobox pattern sizes <PopoverContent>, the palette inherits <DialogContent size="small">.

States

Input

StateLook
Resting / focused--background context, --foreground text, --muted-foreground placeholder and Search glyph, a --border underline. No focus ring on the input itself — the surrounding surface is the focus context

Row (`CommandItem`)

StateLook
Resting--popover-foreground text, transparent background, --radius-sm
Active (arrow / Home / End cursor, or pointer over the row)--accent background + --accent-foreground text — pointer movement moves the active cursor onto the row so keyboard and mouse never disagree
Disabledaria-disabled="true", opacity-50; still shown when it matches the query, but skipped by the arrow keys and never the active row — Enter / click do nothing
Filtered outhidden — no box, not in the a11y tree

List

StateLook / behaviour
Has matchesrows render; CommandEmpty is absent
No matchesevery row / group / separator is hidden; CommandEmpty shows its copy in --muted-foreground, announced politely

Usage Guidance

Combobox vs. Command palette

Command by itself is neither a popover nor a dialog. Two patterns compose it, and keeping them distinct is the point of this page:

ComboboxCommand palette
Container<Popover> (non-modal, anchored)<Dialog> via <CommandDialog> (modal)
Opened byan Input-styled disclosure button showing the current valueany control, plus a caller-wired ⌘K / Ctrl+K shortcut
Purposecommit one value back to a fieldrun an action
DismissalEscape / outside pointerdown / trigger scrolled offscreen (Popover's contract)Escape / overlay click (Dialog's contract) — focus trapped, background inert
Ships asa documented pattern — no dedicated Combobox component<CommandDialog>, exported alongside Command

The disclosure button is not itself a role="combobox" — it carries aria-haspopup="dialog"

  • aria-expanded from Popover. The one combobox in the composition is <CommandInput> inside the surface. This avoids the nested-combobox ambiguity a button-as-combobox would create.

<CommandDialog> deliberately composes the public Dialog API (<Dialog> / <DialogContent>) rather than reaching into Dialog's internals, so the palette inherits Dialog's real focus trap, inert siblings, scroll lock, and focus restore unchanged. The consequence: the V1 palette is vertically centred like any Dialog. A top-anchored palette would need a Dialog change and is deferred.

Active item vs. selection

There is no "committed value" inside Command — it hands value to onSelect and the surrounding pattern decides what that means (commit to a field, run an action). The only internal state that looks like selection is the active item: a cursor the arrow keys, Home / End, and pointer movement move, exposed as aria-activedescendant on the input and styled with --accent. After every filter change the active item resets to the first match. A pre-existing committed value that the current filter hides is left untouched — filtering never clears it.

Tokens

Command uses existing Monogem tokens — the same --popover surface family Popover / Dropdown Menu / Select use, plus --accent / --accent-foreground for the active row (identical to those three, so all four read as siblings) and --muted-foreground for the search glyph, group headings, and the empty state. It adds no dependency; it introduces no component-specific styling values.

TokenWhere usedRationale
--popover / --popover-foregroundSurface fill + textThe "menus, tooltips" pair — shared with Popover / Dropdown Menu / Select
--borderCommandInput underline, CommandSeparatorThe system hairline
--accent / --accent-foregroundActive row background + textColor names --accent for "selected rows" — identical to Select / Dropdown Menu's active row
--muted-foregroundSearch glyph, CommandGroup heading, CommandEmpty textColor names this for quiet secondary text
--foregroundTyped query textPrimary text on the ambient surface
--radius-lgSurface cornerRadius names "cards, popovers"
--radius-smRow cornerRadius's smallest step, for a full-width row inside --scale-1 padding
--scale-1 (p-1)List paddingThe tight 4px frame Dropdown Menu / Select use
--scale-2 / --scale-1-5 (px-2 py-1.5)Row paddingThe same row rhythm as Dropdown Menu / Select, so all three read as siblings
--scale-3 (px-3)CommandInput inline paddingMatches Input
--scale-10 (h-10)CommandInput heightInput's default height step
--scale-4 (size-4)Search + leading-item glyph sizeIcons' inline size

Text recipe: input text-sm leading-normal; rows text-sm leading-snug; group headings text-xs font-medium leading-snug.

The Combobox disclosure button and any field trigger reuse Input's --input / --ring / --destructive treatment via the same class recipe Select's trigger uses (not by importing inputVariants).

Do / Don’t

Do

  • Reach for Command only when the list is long enough that typing to narrow it is a real help. A short, stable list is a Select; a handful of actions off a button is a Dropdown Menu.
  • Give <CommandList> an aria-label, and <CommandInput> a placeholder and an aria-label (or an associated <Label>).
  • Always render a <CommandEmpty> — an empty listbox with no explanation is a dead end.
  • In the Combobox pattern, move focus back to the disclosure button after a commit or a dismiss (the showcase does this with a ref) — Popover only restores focus itself on Escape.
  • Wire ⌘K for a palette in your app, not by expecting the component to. Pair it with Ctrl+K for non-mac and always preventDefault the browser default.
  • Use keywords for terms people will search but you don't want to show ("delete" → an item labelled "Move to trash").

Don’t

  • Don't put more than a leading icon and a label in a row. A row is one selectable thing.
  • Don't rely on Escape clearing the query first and closing second — in Monogem, Escape closes immediately (consistent with Select / Popover / Dialog).
  • Don't use shouldFilter={false} and then forget to filter — you'll show every row regardless of the query.
  • Don't expect fuzzy ranking. V1 filtering is plain substring.
  • Don't nest a Combobox popover inside another popover in V1 — the collision and focus chains are not designed for it.

Accessibility

  • Roles: CommandInput is role="combobox" + aria-controls + aria-activedescendant + aria-autocomplete="list", with aria-expanded="true" for as long as it is mounted (the listbox is always displayed while the surrounding Popover / Dialog is open — aria-expanded tracks the popup's visibility, not whether the current query has any matches); CommandList is role="listbox"; rows are role="option" with aria-selected on the active one (or none); groups are role="group" with aria-labelledby → their heading; separators are role="separator".

  • Focus model: DOM focus stays on CommandInput the entire time the surface is open — identical to Select, except the trigger is a real editable text field. The active row is conveyed only by aria-activedescendant, and is kept scrolled into view. Nothing in the list is a tab stop.

  • Keyboard:

    KeyBehaviour
    Printable / Backspaceedit the filter; the active row resets to the first match
    ArrowDown / ArrowUpmove the active row — does not wrap at the ends (native <select> / Select parity, the opposite of Dropdown Menu)
    Home / Endactive row to the first / last visible row
    Enteractivate the active row (onSelect); the surrounding pattern then commits or runs and closes
    Escapeclose the surface — Popover keeps the committed value, Dialog just closes. No clear-then-close two-step
    TabCombobox: leaves the Popover, closing it; palette: trapped by the Dialog
  • Disabled rows are aria-disabled="true": they stay visible while they match the current query (greyed, opacity-50), count toward hasResults and their group's visibility so the list never contradicts itself, and are skipped by the arrow keys and the active-option cursor — Enter / click on one does nothing. This matches the way Select keeps a disabled <option> visible but unselectable.

  • Empty state: CommandEmpty is an aria-live="polite" region so "no results" is announced as the user types past the last match.

  • Palette modality: CommandDialog is a real Dialog — background inert, focus trapped, focus restored to the opener on close. The Combobox Popover is non-modal — the page behind it stays operable.

  • Contrast: --accent-foreground on --accent is the system-wide active-row pairing, clearing WCAG 2.1 AA in both themes; resting --popover-foreground on --popover is near-maximal.

Related Components / Patterns

  • Select — picking one value from a list.
  • Dropdown Menu — a menu of commands or actions.
  • Popover — hosts a Combobox.
  • Search — narrowing content by typing.