Combobox
A filterable select. A text input narrows a floating list of options as you type, and you pick from the matches — for single or multiple selection, with an async loading story and a real validation state.
Basic
Type to filter; the popup opens as you type and Enter selects the highlighted option. The field follows the rest of the field family — a filled surface, a calm ring-less focus that shifts only the border, and a subtle rest shadow — while the option rows reuse the menu highlight idiom (a neutral fill, not the accent).
"use client";
import { Combobox } from "@stridge/noctis";
const FRUITS = ["Apple", "Banana", "Blueberry", "Cherry", "Grape", "Mango", "Orange", "Pear"];
export default function ComboboxBasic() {
return (
<div className="w-72">
<Combobox.Root items={FRUITS}>
<Combobox.Input aria-label="Fruit" placeholder="Search fruit…" />
<Combobox.Content>
<Combobox.Empty>No fruit found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Sizes
Two control heights — medium and large — sharing the field type and spacing rhythm. Set size on Combobox.Input (or once on Combobox.Root to apply it to the field).
"use client";
import { Combobox } from "@stridge/noctis";
const SIZES: Combobox.Size[] = ["md", "lg"];
const TIMEZONES = ["UTC", "Europe/London", "America/New_York", "Asia/Tokyo"];
export default function ComboboxSizes() {
return (
<div className="flex w-72 flex-col gap-4">
{SIZES.map((size) => (
<Combobox.Root key={size} items={TIMEZONES}>
<Combobox.Input size={size} aria-label={`Timezone (${size})`} placeholder="Search timezone…" />
<Combobox.Content>
<Combobox.Empty>No timezone found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
))}
</div>
);
}
Trigger and clear
Add a Combobox.Trigger to open the list with a click — its chevron rotates while open — and a Combobox.Clear to reset the value, shown only while the field holds one. Wrap them with the input in a Combobox.InputGroup: the input reserves trailing room so the stacked buttons pin to the field's inline-end without ever sitting over the typed text.
"use client";
import { Combobox } from "@stridge/noctis";
const LANGUAGES = ["TypeScript", "JavaScript", "Rust", "Go", "Python", "Ruby", "Swift", "Kotlin"];
export default function ComboboxAffordances() {
return (
<div className="w-72">
<Combobox.Root items={LANGUAGES} defaultValue="TypeScript">
<Combobox.InputGroup>
<Combobox.Input aria-label="Language" placeholder="Search languages…" />
<Combobox.Clear aria-label="Clear" />
<Combobox.Trigger aria-label="Open languages" />
</Combobox.InputGroup>
<Combobox.Content>
<Combobox.Empty>No language found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Leading icon
Add a Combobox.Icon as the first child of the InputGroup for a leading adornment — a search magnifier by default — signalling that the field is a filter. It reserves a leading column and mirrors to the inline-start under RTL.
"use client";
import { Combobox } from "@stridge/noctis";
const COMMANDS = ["Open file…", "Go to symbol…", "Find in files…", "Toggle terminal", "Run task…", "Format document"];
export default function ComboboxLeadingIcon() {
return (
<div className="w-72">
<Combobox.Root items={COMMANDS}>
<Combobox.InputGroup>
<Combobox.Icon />
<Combobox.Input aria-label="Command" placeholder="Search commands…" />
</Combobox.InputGroup>
<Combobox.Content>
<Combobox.Empty>No command found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Async loading
A combobox's defining use case is autocomplete from a server. Set loading on Combobox.Root while a request is in flight — the trailing chevron becomes a spinner — and render a Combobox.Status inside the Content so a screen reader hears the loading state and the results count politely. Disable the built-in filter (filter={null}) when you filter on the server, and debounce the input. Keep already-selected items in the list so the current value stays valid across fetches.
"use client";
import { Combobox } from "@stridge/noctis";
import { useEffect, useState } from "react";
const COUNTRIES = [
"Argentina",
"Australia",
"Brazil",
"Canada",
"Denmark",
"Egypt",
"France",
"Germany",
"India",
"Japan",
"Kenya",
"Mexico",
"Norway",
"Portugal",
"Spain",
];
export default function ComboboxAsync() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<string[]>(COUNTRIES);
const [loading, setLoading] = useState(false);
// Debounce the "fetch": filtering happens off the network, so disable Base UI's built-in filter
// (`filter={null}`) and feed the resolved results in as `items`.
useEffect(() => {
setLoading(true);
const id = setTimeout(() => {
const q = query.trim().toLowerCase();
setResults(q ? COUNTRIES.filter((c) => c.toLowerCase().includes(q)) : COUNTRIES);
setLoading(false);
}, 450);
return () => clearTimeout(id);
}, [query]);
return (
<div className="w-72">
<Combobox.Root items={results} filter={null} loading={loading} onInputValueChange={setQuery}>
<Combobox.InputGroup>
<Combobox.Input aria-label="Country" placeholder="Search countries…" />
<Combobox.Trigger aria-label="Open countries" />
</Combobox.InputGroup>
<Combobox.Content>
<Combobox.Status>
{loading ? "Loading…" : `${results.length} ${results.length === 1 ? "result" : "results"}`}
</Combobox.Status>
<Combobox.Empty>No countries match “{query}”.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
In a form
Compose the combobox inside a Field.Root for the full label, description, and error contract. The field reports invalid through Field automatically; set invalid on Combobox.Input as an additive override. Invalid shifts the border to the danger role — the same recipe as Input, no bespoke red and no extra ring.
Who should own this issue.
"use client";
import { Combobox, Field } from "@stridge/noctis";
import { useState } from "react";
const ASSIGNEES = ["Owner", "Ada", "Grace", "Linus", "Margaret"];
export default function ComboboxValidation() {
// "Owner" is already taken — picking it reports the field invalid; any other choice clears it.
const [value, setValue] = useState<string | null>("Owner");
const invalid = value === "Owner";
return (
<Field.Root invalid={invalid} className="w-72">
<Field.Label>Assignee</Field.Label>
<Combobox.Root items={ASSIGNEES} value={value} onValueChange={setValue}>
<Combobox.InputGroup>
<Combobox.Input invalid={invalid} aria-label="Assignee" placeholder="Select an assignee…" />
<Combobox.Trigger aria-label="Open assignees" />
</Combobox.InputGroup>
<Combobox.Content>
<Combobox.Empty>No one found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
<Field.Description>Who should own this issue.</Field.Description>
<Field.Error match={invalid}>Owner is already assigned. Pick someone else.</Field.Error>
</Field.Root>
);
}
Match highlighting
Bold the matched substring with a <mark data-slot="noctis-combobox-item-match"> around it as you render each row. The mark is weight-and-foreground only — no highlight background, never the accent — so the match reads clearly while the list stays quiet.
"use client";
import { Combobox } from "@stridge/noctis";
import { type ReactNode, useState } from "react";
const FRAMEWORKS = ["Next.js", "Remix", "SvelteKit", "Nuxt", "Astro", "SolidStart", "Qwik City"];
/** Wrap the matched substring in a styled `<mark>` — bolded foreground, no highlight background. */
function highlight(text: string, query: string): ReactNode {
const q = query.trim();
if (!q) return text;
const index = text.toLowerCase().indexOf(q.toLowerCase());
if (index === -1) return text;
return (
<>
{text.slice(0, index)}
<mark data-slot="noctis-combobox-item-match">{text.slice(index, index + q.length)}</mark>
{text.slice(index + q.length)}
</>
);
}
export default function ComboboxHighlighting() {
const [query, setQuery] = useState("");
return (
<div className="w-72">
<Combobox.Root items={FRAMEWORKS} onInputValueChange={setQuery}>
<Combobox.Input aria-label="Framework" placeholder="Search frameworks…" />
<Combobox.Content>
<Combobox.Empty>No framework found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{highlight(item, query)}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Multiple selection
Set multiple on Combobox.Root and wrap the chips and the input in a Combobox.ChipsInput — the multi-select field shell that carries the field fill, border, and a single ring on :focus-within, so the selection reads as one field rather than floating chips. Render the selected values with Combobox.Chips, Combobox.Value, Combobox.Chip, and Combobox.ChipRemove (which brightens on hover).
"use client";
import { Combobox } from "@stridge/noctis";
const LABELS = ["bug", "docs", "enhancement", "good first issue", "help wanted", "question"];
export default function ComboboxMultiple() {
return (
<div className="w-80">
<Combobox.Root items={LABELS} multiple defaultValue={["bug", "help wanted"]}>
<Combobox.ChipsInput>
<Combobox.Chips>
<Combobox.Value>
{(values: string[]) => (
<>
{values.map((value) => (
<Combobox.Chip key={value} aria-label={value}>
{value}
<Combobox.ChipRemove aria-label={`Remove ${value}`} />
</Combobox.Chip>
))}
<Combobox.Input aria-label="Labels" placeholder="Add labels…" />
</>
)}
</Combobox.Value>
</Combobox.Chips>
</Combobox.ChipsInput>
<Combobox.Content>
<Combobox.Empty>No label found.</Combobox.Empty>
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Grouped options
Group related options under headings with Combobox.Group and Combobox.GroupLabel, mapping each group's filtered items through Combobox.Collection. Add a Combobox.Separator between groups for a thin neutral divider.
"use client";
import { Combobox } from "@stridge/noctis";
interface ProduceGroup {
value: string;
items: string[];
}
const GROUPS: ProduceGroup[] = [
{ value: "Fruit", items: ["Apple", "Banana", "Cherry"] },
{ value: "Vegetable", items: ["Carrot", "Potato", "Spinach"] },
];
export default function ComboboxGrouped() {
return (
<div className="w-72">
<Combobox.Root items={GROUPS}>
<Combobox.Input aria-label="Produce" placeholder="Search produce…" />
<Combobox.Content>
<Combobox.Empty>Nothing found.</Combobox.Empty>
<Combobox.List>
{(group: ProduceGroup) => (
<Combobox.Group key={group.value} items={group.items}>
<Combobox.GroupLabel>{group.value}</Combobox.GroupLabel>
<Combobox.Collection>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.Collection>
</Combobox.Group>
)}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Structured options
An option is just composition — give a row a leading avatar or icon, a primary label, a secondary description line, and trailing meta. The leading check column is reserved by Combobox.Item regardless, so structured rows still align.
"use client";
import { Combobox } from "@stridge/noctis";
interface Person {
name: string;
initials: string;
email: string;
role: string;
}
const PEOPLE: Person[] = [
{ name: "Ada Lovelace", initials: "AL", email: "ada@example.com", role: "Owner" },
{ name: "Grace Hopper", initials: "GH", email: "grace@example.com", role: "Admin" },
{ name: "Linus Torvalds", initials: "LT", email: "linus@example.com", role: "Member" },
{ name: "Margaret Hamilton", initials: "MH", email: "margaret@example.com", role: "Member" },
];
const byName = (name: string) => PEOPLE.find((p) => p.name === name);
export default function ComboboxStructured() {
return (
<div className="w-80">
<Combobox.Root items={PEOPLE.map((p) => p.name)}>
<Combobox.Input aria-label="Assignee" placeholder="Assign to…" />
<Combobox.Content>
<Combobox.Empty>No one found.</Combobox.Empty>
<Combobox.List>
{(name: string) => {
const person = byName(name);
if (!person) return null;
return (
<Combobox.Item key={name} value={name}>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border border-border text-mini text-muted">
{person.initials}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{person.name}</span>
<span className="truncate text-mini text-muted">{person.email}</span>
</span>
<span className="shrink-0 text-mini text-muted">{person.role}</span>
</Combobox.Item>
);
}}
</Combobox.List>
</Combobox.Content>
</Combobox.Root>
</div>
);
}
Create a new value
There is no dedicated create slot — compose it. Allow free text with allowsCustomValue on Combobox.Root, then offer a create action inside Combobox.Empty (e.g. a button that commits the current query as a new value) so the no-results state turns into Create "<query>".
Long lists
For hundreds or thousands of rows (countries, timezones), pass virtualized to Combobox.Root and render the Combobox.List with the virtualized item API from Base UI — only the visible rows mount. Keep the item height fixed so the virtualizer can measure it.
Keyboard
Filtering and navigation are Base UI's, fully keyboard-operable and RTL-aware.
| Key | Action |
|---|---|
| Type | Filters the list and opens the popup |
Down / Up | Move the highlight between options |
Home / End | Jump to the first / last option |
Enter | Select the highlighted option |
Escape | Close the popup, keeping the field focused |
Backspace | In multiple mode with an empty field, removes the last chip |
Arrow keys follow the visual list order, so they read the same under RTL; directional glyphs mirror with direction.
Accessibility
- Async announcements.
Combobox.Statusis a polite live region that must stay mounted — Base UI announces its content changes (loading, results count) without moving focus. The trailing spinner also carries a localized hidden "Loading" label. - Affordance labels.
Trigger,Clear, andChipRemoveare icon-only buttons — always give them anaria-label. - Known gap. Base UI's internal dismiss control has a hardcoded English
aria-label("Dismiss") that no consumer prop reaches, so it is not yet translatable from this layer — pending an upstream Base UI change. Everything else localizes through the active locale. - Reduced motion. The spinner, the popup scale, and the chevron rotation all respect
prefers-reduced-motion.
Anatomy
Compose a combobox from its parts. Combobox.Root owns the value and filtering (controlled via value / onValueChange, or uncontrolled via defaultValue) and shares the control size and the loading flag.
Combobox.Root— the container. Props:size(defaultmd),loading,multiple, plus the Base UICombobox.Rootprops (items,filter,filteredItems,value,defaultValue,onValueChange,onInputValueChange,virtualized,allowsCustomValue,open,defaultOpen).Combobox.Input— the text field. Type to filter; takes an optionalsizeoverride and aninvalidflag.Combobox.InputGroup— the field shell that lays the input beside its leadingIconand trailingTrigger/Clear.Combobox.ChipsInput— the multi-select field shell: a filled, bordered box wrapping theChips+Input, ringing on:focus-within.Combobox.Icon— a leading decorative field glyph (search magnifier by default).Combobox.Trigger— a button that opens the popup; renders a chevron that rotates while open, or a spinner while the root is loading.Combobox.Clear— a button that resets the value, shown only while the field holds one.Combobox.Content— the floating, elevated listbox surface (portaled throughSurface).Combobox.Status— a polite live region for async messages, rendered insideContent.Combobox.List— the scrollable listbox; accepts an item-render child.Combobox.Item— one option, with a leading check that appears once selected.Combobox.Empty— the centred message shown when the filter matches nothing.Combobox.Group/Combobox.GroupLabel/Combobox.Collection/Combobox.Separator— a labelled section, its filtered rows, and the divider between sections.Combobox.Chips/Combobox.Value/Combobox.Chip/Combobox.ChipRemove— the selected-value chips for multiple selection.
Every rendered part carries a data-slot (combobox-input, combobox-list, combobox-item, combobox-item-indicator, combobox-empty, combobox-group-label, combobox-chip, and the styling-only combobox-input-group, combobox-chips-input, combobox-icon, combobox-trigger, combobox-spinner, combobox-clear, combobox-positioner, combobox-content, combobox-status, combobox-group, combobox-separator, combobox-item-match, combobox-chips, combobox-chip-remove) for host-side styling — pair it with the Base UI state attributes (data-popup-open, data-highlighted, data-selected, data-disabled, data-list-empty, data-invalid).
On surfaces
The same control re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well. It stays legible on every layer.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every combobox in that region retunes — e.g. .brand { --noctis-combobox-item-background-color-highlighted: var(--noctis-color-accent-muted); } recolors the option highlight. Knobs that aren't minted are reached through the part's data-slot. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through. Expand a row for the full type and description.