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 withvalue+onValueChange), the active item id, and whether anything currently matches.filteroverrides 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"witharia-controls→ the list,aria-activedescendant→ the active item, andaria-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— therole="listbox"scroll region. Give it anaria-labelso the list is named.CommandEmpty— your "no results" copy. Renders only whilehasResultsis false; it is anaria-live="polite"region so the change is announced.CommandItem— one row.role="option",aria-selectedwhile it is the active row.value(required) is what the filter matches and whatonSelect(value)receives;keywordsadds extra match terms that aren't shown.disabledmarks 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 thehiddenattribute (out of the layout and the accessibility tree).CommandGroup— a labelled cluster (headingprop). The whole group, heading included, getshiddenonly when filtering has removed every rendered row inside it — a group still showing a disabled (but matching) row stays visible, consistent withhasResults.CommandSeparator— a hairline between groups;hiddenwhenever the filter is non-empty (dividers between filtered fragments read as noise).CommandDialog— the palette container.open/onOpenChangeare caller-owned;titleis 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
"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
| State | Look |
|---|---|
| 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`)
| State | Look |
|---|---|
| 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 |
| Disabled | aria-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 out | hidden — no box, not in the a11y tree |
List
| State | Look / behaviour |
|---|---|
| Has matches | rows render; CommandEmpty is absent |
| No matches | every 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:
| Combobox | Command palette | |
|---|---|---|
| Container | <Popover> (non-modal, anchored) | <Dialog> via <CommandDialog> (modal) |
| Opened by | an Input-styled disclosure button showing the current value | any control, plus a caller-wired ⌘K / Ctrl+K shortcut |
| Purpose | commit one value back to a field | run an action |
| Dismissal | Escape / outside pointerdown / trigger scrolled offscreen (Popover's contract) | Escape / overlay click (Dialog's contract) — focus trapped, background inert |
| Ships as | a 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-expandedfrom 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.
| Token | Where used | Rationale |
|---|---|---|
--popover / --popover-foreground | Surface fill + text | The "menus, tooltips" pair — shared with Popover / Dropdown Menu / Select |
--border | CommandInput underline, CommandSeparator | The system hairline |
--accent / --accent-foreground | Active row background + text | Color names --accent for "selected rows" — identical to Select / Dropdown Menu's active row |
--muted-foreground | Search glyph, CommandGroup heading, CommandEmpty text | Color names this for quiet secondary text |
--foreground | Typed query text | Primary text on the ambient surface |
--radius-lg | Surface corner | Radius names "cards, popovers" |
--radius-sm | Row corner | Radius's smallest step, for a full-width row inside --scale-1 padding |
--scale-1 (p-1) | List padding | The tight 4px frame Dropdown Menu / Select use |
--scale-2 / --scale-1-5 (px-2 py-1.5) | Row padding | The same row rhythm as Dropdown Menu / Select, so all three read as siblings |
--scale-3 (px-3) | CommandInput inline padding | Matches Input |
--scale-10 (h-10) | CommandInput height | Input's default height step |
--scale-4 (size-4) | Search + leading-item glyph size | Icons' 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>anaria-label, and<CommandInput>aplaceholderand anaria-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 onEscape. - Wire
⌘Kfor a palette in your app, not by expecting the component to. Pair it withCtrl+Kfor non-mac and alwayspreventDefaultthe browser default. - Use
keywordsfor 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
Escapeclearing the query first and closing second — in Monogem,Escapecloses 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:
CommandInputisrole="combobox"+aria-controls+aria-activedescendant+aria-autocomplete="list", witharia-expanded="true"for as long as it is mounted (the listbox is always displayed while the surrounding Popover / Dialog is open —aria-expandedtracks the popup's visibility, not whether the current query has any matches);CommandListisrole="listbox"; rows arerole="option"witharia-selectedon the active one (or none); groups arerole="group"witharia-labelledby→ their heading; separators arerole="separator". -
Focus model: DOM focus stays on
CommandInputthe entire time the surface is open — identical to Select, except the trigger is a real editable text field. The active row is conveyed only byaria-activedescendant, and is kept scrolled into view. Nothing in the list is a tab stop. -
Keyboard:
Key Behaviour 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 closesEscapeclose 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 towardhasResultsand 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:
CommandEmptyis anaria-live="polite"region so "no results" is announced as the user types past the last match. -
Palette modality:
CommandDialogis a real Dialog — backgroundinert, focus trapped, focus restored to the opener on close. The Combobox Popover is non-modal — the page behind it stays operable. -
Contrast:
--accent-foregroundon--accentis the system-wide active-row pairing, clearing WCAG 2.1 AA in both themes; resting--popover-foregroundon--popoveris 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.