A text input with a listbox of suggestions (the WAI-ARIA "editable combobox with list autocomplete"). Focus stays in the input; the active option moves with aria-activedescendant. The listbox is a popover="auto" anchored to the input with CSS, so the platform provides the top layer, light dismiss, Escape and nesting inside dialogs. The library filters nothing: you render the options for a query, synchronously or with use() under your own <Suspense>.
"use client";
import { Suspense, startTransition, use, useState } from "react";
import { Combobox } from "@funstack/ui/combobox";
import { DemoErrorBoundary } from "./DemoErrorBoundary.js";
import { fakeSave, sleep } from "./fake-api.js";
interface Country {
readonly code: string;
readonly name: string;
}
const COUNTRIES: readonly Country[] = [
{ code: "ar", name: "Argentina" },
{ code: "au", name: "Australia" },
{ code: "br", name: "Brazil" },
{ code: "ca", name: "Canada" },
{ code: "fr", name: "France" },
{ code: "de", name: "Germany" },
{ code: "in", name: "India" },
{ code: "jm", name: "Jamaica" },
{ code: "jp", name: "Japan" },
{ code: "je", name: "Jersey" },
{ code: "ke", name: "Kenya" },
{ code: "mx", name: "Mexico" },
{ code: "nz", name: "New Zealand" },
{ code: "no", name: "Norway" },
{ code: "es", name: "Spain" },
{ code: "gb", name: "United Kingdom" },
];
// A pretend search API with a cache keyed by query: one promise per query, read with `use()`.
// Results arrive in random order; React only ever renders the latest query's.
const cache = new Map<string, Promise<readonly Country[]>>();
function searchCountries(query: string): Promise<readonly Country[]> {
const key = query.trim().toLowerCase();
let promise = cache.get(key);
if (promise === undefined) {
promise = sleep(300 + Math.random() * 500).then(() =>
COUNTRIES.filter((c) => c.name.toLowerCase().includes(key)),
);
cache.set(key, promise);
}
return promise;
}
function CountryOptions({ query }: { query: string }) {
const countries = use(searchCountries(query));
return countries.map((c) => (
<Combobox.Option key={c.code} value={c.code} label={c.name}>
{({ pending }) => (
<>
{c.name}
{pending && <span className="demo-spinner" aria-hidden />}
</>
)}
</Combobox.Option>
));
}
export function ComboboxCountry() {
const [country, setCountry] = useState<string | null>(null);
const [fail, setFail] = useState(false);
return (
<div className="demo-stack">
<DemoErrorBoundary>
<Combobox.Root
valueChangeAction={async (next) => {
await fakeSave(`country ${next}`, { fail });
startTransition(() => setCountry(next));
}}
>
<Combobox.Label>Country</Combobox.Label>
<div className="demo-combobox-field">
<Combobox.Input className="demo-combobox-input" placeholder="Type to search" />
<Combobox.Clear className="demo-combobox-icon demo-combobox-clear" aria-label="Clear">
×
</Combobox.Clear>
<Combobox.Trigger className="demo-combobox-icon" aria-label="Show countries">
▾
</Combobox.Trigger>
<Combobox.Popup
className="demo-combobox-popup"
transition={{
"fsui-combobox-open": "demo-combobox-pop",
"fsui-combobox-close": "demo-combobox-pop",
default: "none",
}}
>
<Combobox.List>
{({ query }) => (
<Suspense
fallback={
<Combobox.Status className="demo-combobox-status">Searching…</Combobox.Status>
}
>
<CountryOptions query={query} />
</Suspense>
)}
</Combobox.List>
<Combobox.Empty className="demo-combobox-status">No country matches.</Combobox.Empty>
</Combobox.Popup>
</div>
</Combobox.Root>
</DemoErrorBoundary>
<label>
<input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} /> Make
the save fail
</label>
<output className="demo-readout">
saved: <code>{JSON.stringify(country)}</code>
</output>
</div>
);
}/* Combobox: the Input is a text field with role="combobox"; the Popup is a <div popover="auto">
placed below it by the library's inline anchor positioning; the List inside is the listbox.
The active option (virtual focus) carries `data-active`, the selected one `data-selected`, the
one being saved `data-pending`; stale results carry `data-stale` on the Popup and the List. */
.demo-combobox-field {
display: inline-flex;
align-items: center;
gap: 2px;
padding-inline-end: 2px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
}
.demo-combobox-field:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.demo-combobox-input {
min-inline-size: 14rem;
padding: 0.4em 0.6em;
border: 0;
outline: none;
background: transparent;
color: var(--color-text);
font: inherit;
}
.demo-combobox-input[data-pending] {
cursor: progress;
}
.demo-combobox-icon {
padding: 0.2em 0.45em;
border: 0;
background: none;
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
}
/* Nothing to clear: hide Clear (the input shows its placeholder when empty). */
.demo-combobox-field:has(.demo-combobox-input:placeholder-shown) .demo-combobox-clear {
visibility: hidden;
}
.demo-combobox-popup {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
margin-block-start: 4px;
inset: auto;
max-block-size: 16rem;
overflow: auto;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
/* CSS enter / exit, used by dismissals (Escape, a click outside). */
opacity: 0;
transition:
opacity 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-combobox-popup:popover-open {
opacity: 1;
}
@starting-style {
.demo-combobox-popup:popover-open {
opacity: 0;
}
}
/* Old results while the next ones load. */
.demo-combobox-popup [role="listbox"][data-stale] {
opacity: 0.55;
transition: opacity 150ms 100ms;
}
.demo-combobox-popup [role="option"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 6px 10px;
border-radius: 4px;
cursor: default;
}
/* Virtual focus; hovering makes an option active too, so this is the hover style as well. */
.demo-combobox-popup [role="option"][data-active] {
background: var(--color-bg-secondary);
}
.demo-combobox-popup [role="option"][data-selected]::after {
content: "✓";
color: var(--color-primary);
}
.demo-combobox-popup [role="option"][data-pending] {
cursor: progress;
}
.demo-combobox-status {
padding: 6px 10px;
color: var(--color-text-secondary);
}
/* The library's view transition types: animate the library-driven open and close, nothing else
(a results change would cross-fade otherwise). */
::view-transition-old(.demo-combobox-pop) {
animation: 120ms ease-in both demo-combobox-out;
}
::view-transition-new(.demo-combobox-pop) {
animation: 120ms ease-out both demo-combobox-in;
}
@keyframes demo-combobox-out {
to {
opacity: 0;
}
}
@keyframes demo-combobox-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-combobox-popup {
transition: none;
}
}"Everything is a transition" has one exception, and this is it: React requires a controlled text input to update urgently, in the same event as the keystroke. So the text is the library's first urgent state. It keeps the usual triple, inputValue / defaultInputValue / inputValueChangeAction, but the action's type is UrgentChangeAction: it is called synchronously, outside any transition, and never awaited. A controlled parent simply sets its state in the body:
const [text, setText] = useState("");
<Combobox.Root inputValue={text} inputValueChangeAction={(next) => setText(next)}>Everything downstream of the text leaves the urgent lane at once:
query, which is useDeferredValue(inputValue). A slow search never blocks a keystroke; the previous results stay on screen (no fallback flash) with data-stale and aria-busy="true" until the new ones are ready; and only the latest query is ever rendered, so a slow response for "ja" cannot overwrite the results for "jap".openChangeAction).valueChangeAction) with an optimistic selection, exactly as every other value in the library.While any async action is pending in the app (this combobox's own slow save included), React holds every transition. Typing is unaffected (urgent), and so are the results: a deferred render is not held by a pending action (verified in Chromium and Firefox). Opening the listbox while a save is pending is a transition, so it waits for the save: the input shows aria-expanded="true" and data-pending meanwhile.
import { Combobox } from "@funstack/ui/combobox";
<Combobox.Root name="country" valueChangeAction={saveCountry}> {/* no DOM; a hidden input with name */}
<Combobox.Label>Country</Combobox.Label> {/* label[for] */}
<Combobox.Input placeholder="Type to search" required /> {/* input[role=combobox] */}
<Combobox.Clear aria-label="Clear" /> {/* button, optional */}
<Combobox.Trigger aria-label="Show all countries" /> {/* button, optional */}
<Combobox.Popup className="listbox"> {/* div[popover=auto] */}
<Combobox.List> {/* div[role=listbox] */}
{({ query }) => (
<Suspense fallback={<Combobox.Status>Searching…</Combobox.Status>}>
<CountryOptions query={query} /> {/* Combobox.Option… */}
</Suspense>
)}
</Combobox.List>
<Combobox.Empty>No country matches.</Combobox.Empty> {/* only when settled and empty */}
</Combobox.Popup>
</Combobox.Root>value (string | null) is what is chosen; inputValue is what is typed. The library writes the text itself only at defined moments: typing, an accepted option (its label, else its text), a revert, Clear, a form reset, and text typed before hydration. It never derives the text from value, so a preselected value needs its text too: <Combobox.Root defaultValue={user.country} defaultInputValue={user.countryName}> (development warns otherwise).Empty and a hint Status go in the Popup, outside the List. A Status inside the List (the Suspense fallback above) marks the results as loading: it renders with role="none" (no live region inside the listbox), the List gets aria-busy="true", Empty stays hidden and nothing is announced until the results arrive.showPopover({ source: input }): the input is its implicit anchor and clicks on the input never light-dismiss it. Render the Popup next to the Input.commandfor: the listbox is always anchored to the input and opened by the library. Before hydration the input is a plain text field; text typed then is kept and adopted at mount, and nothing opens until the next keystroke.Enter on the active option, or a click: the input text becomes the option's label at once, the option gets data-selected and data-pending, and valueChangeAction runs in the Root's transition while the listbox stays open. When it settles the listbox closes in the same commit. A rejection rolls the text and the selection back, leaves the listbox open and reaches the nearest Error Boundary. Re-choosing the selected option dispatches nothing and closes. If you type a new search while the save runs, the listbox stays open when it settles.
A controlled "update on success" parent (startTransition(() => setValue(next)) after its await) works as everywhere. A parent that refuses a value gets the text and the selection reverted; the listbox closes anyway, because the close is part of the action's commit.
"use client";
import { useState } from "react";
import { Combobox } from "@funstack/ui/combobox";
const SUGGESTIONS = ["Coffee", "Tea", "Matcha", "Hot chocolate", "Lemonade"];
// Free text: `allowCustomValue` makes the typed text the value (Enter with no active
// suggestion, or leaving the field). The text is controlled with a plain setState in the urgent
// `inputValueChangeAction`. `name` puts the value in a hidden input for the form.
export function ComboboxDrink() {
const [text, setText] = useState("");
const [submitted, setSubmitted] = useState<string | null>(null);
return (
<form
className="demo-stack"
onSubmit={(event) => {
event.preventDefault();
setSubmitted(String(new FormData(event.currentTarget).get("drink")));
}}
>
<Combobox.Root
name="drink"
allowCustomValue
inputValue={text}
inputValueChangeAction={(next) => setText(next)}
>
<Combobox.Label>Favourite drink</Combobox.Label>
<div className="demo-combobox-field">
<Combobox.Input className="demo-combobox-input" placeholder="Anything" required />
<Combobox.Popup className="demo-combobox-popup">
<Combobox.List>
{({ query }) =>
SUGGESTIONS.filter((s) => s.toLowerCase().includes(query.toLowerCase())).map(
(s) => (
<Combobox.Option key={s} value={s}>
{s}
</Combobox.Option>
),
)
}
</Combobox.List>
<Combobox.Empty className="demo-combobox-status">
Press Enter to use your own.
</Combobox.Empty>
</Combobox.Popup>
</div>
</Combobox.Root>
<div className="demo-row">
<button type="submit" className="demo-button demo-primary">
Submit
</button>
<output className="demo-readout">
text: <code>{JSON.stringify(text)}</code>, submitted:{" "}
<code>{JSON.stringify(submitted)}</code>
</output>
</div>
</form>
);
}/* Combobox: the Input is a text field with role="combobox"; the Popup is a <div popover="auto">
placed below it by the library's inline anchor positioning; the List inside is the listbox.
The active option (virtual focus) carries `data-active`, the selected one `data-selected`, the
one being saved `data-pending`; stale results carry `data-stale` on the Popup and the List. */
.demo-combobox-field {
display: inline-flex;
align-items: center;
gap: 2px;
padding-inline-end: 2px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg);
}
.demo-combobox-field:focus-within {
outline: 2px solid var(--color-primary);
outline-offset: 1px;
}
.demo-combobox-input {
min-inline-size: 14rem;
padding: 0.4em 0.6em;
border: 0;
outline: none;
background: transparent;
color: var(--color-text);
font: inherit;
}
.demo-combobox-input[data-pending] {
cursor: progress;
}
.demo-combobox-icon {
padding: 0.2em 0.45em;
border: 0;
background: none;
color: var(--color-text-secondary);
font: inherit;
cursor: pointer;
}
/* Nothing to clear: hide Clear (the input shows its placeholder when empty). */
.demo-combobox-field:has(.demo-combobox-input:placeholder-shown) .demo-combobox-clear {
visibility: hidden;
}
.demo-combobox-popup {
/* The popover UA stylesheet sets `inset: 0; margin: auto`: reset both for anchored placement. */
margin: 0;
margin-block-start: 4px;
inset: auto;
max-block-size: 16rem;
overflow: auto;
padding: 4px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
color: var(--color-text);
box-shadow: 0 12px 40px rgb(0 0 0 / 0.25);
/* CSS enter / exit, used by dismissals (Escape, a click outside). */
opacity: 0;
transition:
opacity 120ms,
display 120ms allow-discrete,
overlay 120ms allow-discrete;
}
.demo-combobox-popup:popover-open {
opacity: 1;
}
@starting-style {
.demo-combobox-popup:popover-open {
opacity: 0;
}
}
/* Old results while the next ones load. */
.demo-combobox-popup [role="listbox"][data-stale] {
opacity: 0.55;
transition: opacity 150ms 100ms;
}
.demo-combobox-popup [role="option"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 6px 10px;
border-radius: 4px;
cursor: default;
}
/* Virtual focus; hovering makes an option active too, so this is the hover style as well. */
.demo-combobox-popup [role="option"][data-active] {
background: var(--color-bg-secondary);
}
.demo-combobox-popup [role="option"][data-selected]::after {
content: "✓";
color: var(--color-primary);
}
.demo-combobox-popup [role="option"][data-pending] {
cursor: progress;
}
.demo-combobox-status {
padding: 6px 10px;
color: var(--color-text-secondary);
}
/* The library's view transition types: animate the library-driven open and close, nothing else
(a results change would cross-fade otherwise). */
::view-transition-old(.demo-combobox-pop) {
animation: 120ms ease-in both demo-combobox-out;
}
::view-transition-new(.demo-combobox-pop) {
animation: 120ms ease-out both demo-combobox-in;
}
@keyframes demo-combobox-out {
to {
opacity: 0;
}
}
@keyframes demo-combobox-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.demo-combobox-popup {
transition: none;
}
}With allowCustomValue the typed text is a value: Enter with no active option, or focus leaving, dispatches it with details.custom: true (an emptied text dispatches null). Enter on a closed listbox is not prevented, so a form still submits, and the hidden input already holds the new text. Without allowCustomValue, text that is not the selection's label reverts when focus leaves (reason: "dismiss"), so the field never shows a value it does not have.
Combobox.Root| Prop | Type | Default | Description |
|---|---|---|---|
value | string | null | – | Controlled selection. null: nothing selected. undefined means uncontrolled. |
defaultValue | string | null | null | Initial selection when uncontrolled; what a form reset returns to. |
valueChangeAction | ChangeAction<string | null, ComboboxValueChangeDetails> | – | Runs in the Root's transition when the user accepts an option, commits free text, presses Clear, or the form resets. Awaited; the listbox closes in the commit that ends it; a rejection rolls the text and the selection back and leaves the listbox open. May be a Server Function. A setState after your own await needs startTransition. |
inputValue | string | – | Controlled text. undefined means uncontrolled. |
defaultInputValue | string | "" | Initial text when uncontrolled, and the visible input's form-reset target. Pass the selected option's label together with defaultValue. |
inputValueChangeAction | UrgentChangeAction<string, ComboboxInputValueChangeDetails> | – | The urgent lane: called synchronously, outside any transition, on every change of the text (typing, and the library's own writes). Not awaited, never pending. Set your state in the body. |
open | boolean | – | Controlled open state. undefined means uncontrolled. |
defaultOpen | boolean | false | Initial open state; opens after hydration. |
openChangeAction | ChangeAction<boolean, ComboboxOpenChangeDetails> | – | Awaited for opens and for the closes the library performs (an accepted option, the Trigger); a notification for dismissals (Escape, a click outside, focus leaving). |
allowCustomValue | boolean | false | Free text is a value (Enter with no active option, focus leaving), dispatched with details.custom: true. Otherwise unmatched text reverts when focus leaves. |
autoHighlight | boolean | false | Make the first enabled option active when the results for an open or an edit settle (APG automatic selection): typing then Enter picks the first match. Stale results are never highlighted, so an Enter before they settle commits the typed text (allowCustomValue) or does nothing. |
openOnClick | boolean | false | Open on a click in the input (never on focus alone). |
loop | boolean | false | ArrowDown / ArrowUp wrap from the last option to the first and back. |
announce | (count: number, state: ComboboxRootState) => string | null | "No results" / "1 result" / "N results" | The message ariaNotify() announces on the List when the results settle. Override for localisation; return null to announce nothing. Ignored where ariaNotify is missing. |
name | string | – | Renders <input type="hidden" name> holding the optimistic value ("" for null), the Root's only element. |
form | string | – | The hidden input's form attribute. |
disabled | boolean | false | Disables the Input, Trigger and Clear; nothing dispatches for user interaction. The hidden input stays enabled, so the selection still posts (wrap in <fieldset disabled> to drop it). |
children | ReactNode | ((state: ComboboxRootState) => ReactNode) | – | The parts, or a function of the Root's state. |
ComboboxRootState: { value, inputValue, query, stale, activeValue, open, pending, disabled }. value and open are optimistic; stale is inputValue !== query; pending covers the value and the open transitions. No ref, className or style: the Root has no element. multiple is not part of this version.
ComboboxValueChangeDetails: { reason, custom, event }. reason is "activate" (an accepted option: the keydown or click; free text: the keydown or focusout; Clear: the click) or "platform" (a form reset: the reset event). ComboboxInputValueChangeDetails: { reason, event }, with "activate" for typing (the input event), an accepted label and Clear, "dismiss" for a revert, "platform" for a reset or text found at mount (no event). ComboboxOpenChangeDetails: { reason, event }, as Popover's.
Combobox.Input| Prop | Type | Default | Description |
|---|---|---|---|
required | boolean | – | Native: an empty text is valueMissing, with the browser's message. |
className | string | ((state: ComboboxInputState) => string | undefined) | – | A class name, or a function of the input's state. |
style | CSSProperties | ((state: ComboboxInputState) => CSSProperties | undefined) | – | Inline style, or a function of the input's state. |
render | ReactElement | ((props, state: ComboboxInputState) => ReactNode) | – | Replace the default <input> (element form) or take over rendering (function form). |
ComboboxInputState: { value, inputValue, open, pending, stale, disabled }. Owns id, type ("text", or "search" when a render element says so), role, value, autoComplete="off", the aria-autocomplete / aria-expanded / aria-controls / aria-activedescendant wiring, disabled and its onChange, onKeyDown, onBlur, onClick. placeholder, name (a second field with the raw text), aria-describedby and other input props pass through. A render element must stay an <input> of type text or search.
Combobox.Trigger, Combobox.Clear| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Native disabled; the Root's disabled applies too. |
className | string | ((state: ComboboxTriggerState | ComboboxClearState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ComboboxTriggerState | ComboboxClearState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ComboboxTriggerState | ComboboxClearState) => ReactNode) | – | Content, usually an icon; give the button an aria-label (development warns without a name). |
render | ReactElement | ((props, state: ComboboxTriggerState | ComboboxClearState) => ReactNode) | – | Replace the default <button> (element form) or take over rendering (function form). |
Both are <button type="button" tabindex="-1"> (the input is the one Tab stop), keep focus in the input when pressed and focus it when clicked. ComboboxTriggerState: { open, pending, disabled }; the Trigger toggles the listbox and carries aria-expanded, aria-controls. ComboboxClearState: { value, inputValue, disabled }; Clear empties the text and dispatches null unless nothing is selected. It is always rendered: hide it with CSS (:has(input:placeholder-shown)) or its state. A click on Clear while the listbox is open closes it (light dismiss: the button is outside the popup).
Combobox.Popup| Prop | Type | Default | Description |
|---|---|---|---|
keepMounted | boolean | false | false: the children render only while open. true: they stay mounted in a hidden <Activity>. |
transition | ViewTransitionClass | – | The <ViewTransition default> around the popover element. Animates library-driven opens and closes in every engine. A deferred results render activates it too, so use a map with default: "none" (below). Keep it static. |
className | string | ((state: ComboboxPopupState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ComboboxPopupState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ComboboxPopupState) => ReactNode) | – | Content, or a function of the part's state. |
render | ReactElement | ((props, state: ComboboxPopupState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ComboboxPopupState: { open, stale, query } (open committed). Renders <div popover="auto"> with data-stale and no role, and owns id, popover, onPointerDown and onMouseDown (a press anywhere in it keeps focus in the input). Inline style position-area: block-end span-inline-end; position-try-fallbacks: flip-block; min-width: anchor-size(width) places it below the input (above when it does not fit), at least as wide; your style wins per property. A render element must stay a <div>.
Combobox.List| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: ComboboxListState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ComboboxListState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ComboboxListState) => ReactNode) | – | Usually a function of { query } returning the Options for it, under your own <Suspense> for an async search. |
render | ReactElement | ((props, state: ComboboxListState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ComboboxListState: { query, stale, empty }. Renders <div role="listbox">, named by the Label (aria-labelledby, token-merged with yours, only when a Combobox.Label is rendered; without a Label give the List an aria-label, development warns otherwise), with aria-busy="true" while stale (with data-stale) or loading (a Status fallback inside it), and data-empty when settled with no options.
Combobox.Option| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | Required and unique within the Combobox; the option's id is derived from it, so it keeps its id across result sets. |
label | string | – | The text written into the input when the option is accepted. Default: the option's trimmed text at that moment. Not used for matching. |
disabled | boolean | false | aria-disabled: shown, skipped by the arrow keys, never accepted. |
className | string | ((state: ComboboxOptionState) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: ComboboxOptionState) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: ComboboxOptionState) => ReactNode) | – | Content, or a function of the option's state; must not suspend. |
render | ReactElement | ((props, state: ComboboxOptionState) => ReactNode) | – | Replace the default <div> (element form) or take over rendering (function form). |
ComboboxOptionState: { value, selected, active, pending, disabled }. selected is optimistic, active is virtual focus, pending is true on the option being saved. Renders <div role="option" aria-selected>; never focused, no tabindex. Hovering makes it active. Owns id, role, aria-selected, aria-disabled and its pointer and click handlers.
Combobox.Group, Combobox.GroupLabel, Combobox.Empty, Combobox.Status| Prop | Type | Default | Description |
|---|---|---|---|
className | string | ((state: {} (Empty: ComboboxListState)) => string | undefined) | – | A class name, or a function of the part's state. |
style | CSSProperties | ((state: {} (Empty: ComboboxListState)) => CSSProperties | undefined) | – | Inline style, or a function of the part's state. |
children | ReactNode | ((state: {} (Empty: ComboboxListState)) => ReactNode) | – | Content. Empty renders only when the List has no options, the results are not stale and none are loading. |
render | ReactElement | ((props, state) => ReactNode) | – | Replace the default <div>. |
Group is <div role="group" aria-labelledby> named by the GroupLabel inside it. Status inside the List (as a Suspense fallback) is <div role="none"> and marks the results as loading (the List is aria-busy); in the Popup it is a plain hint, <div role="status">. Empty is a plain <div> that renders nothing unless the results are settled and empty, so "No results" never flashes during a search.
| Attribute | On | Meaning |
|---|---|---|
data-open | Input, Trigger | Optimistic open state (aria-expanded). |
data-pending | Input | A selection or an open is in flight (the Root's pending). |
data-pending | Trigger | The open (or close) is in flight. |
data-pending | Option | The option the in-flight selection is choosing. |
data-selected | Input | A value is selected (optimistic, not null). |
data-selected | Option | This option is the optimistic selection. |
data-active | Option | aria-activedescendant points here (keyboard or hover). |
data-stale | Input, Popup, List | The results shown are for an older text; the List also has aria-busy. |
data-empty | List | Settled with no options. |
data-disabled | Input, Trigger, Clear, Option | Disabled. |
:popover-open | Popup | The listbox is shown (browser-owned, no attribute). |
| Key | Listbox | Behaviour |
|---|---|---|
| Typing, Backspace, paste | any | Changes the text (urgent), clears the active option and opens the listbox. |
| ArrowDown / ArrowUp | closed | Opens with the first / last enabled option active. |
| ArrowDown / ArrowUp | open | Next / previous enabled option; stops at the ends (wraps with loop). |
| Alt+ArrowDown | closed | Opens with no option active. |
| Alt+ArrowUp | open | Accepts the active option, or closes. |
| Enter | open | Accepts the active option; with none, commits free text (allowCustomValue) or does nothing. |
| Enter | closed | Not handled (a form submits); commits changed free text first with allowCustomValue. |
| Escape | open | The platform closes the listbox; focus and text stay. A second Escape reaches an enclosing Dialog or Popover. |
| Tab / Shift+Tab | any | Focus moves on and the listbox closes; the text reverts or, with allowCustomValue, commits. Tab never accepts the active option. |
| Home / End, Left / Right | any | The input's caret; the active option is cleared (focus is back on the text). |
Disabled options are skipped. Keys during an IME composition belong to the composition. Ctrl and Meta combinations are left alone.
role="combobox" with aria-autocomplete="list", aria-expanded, aria-controls (the List) and aria-activedescendant (the active option, never a stale id; removed while you edit the text). Name it with Combobox.Label, which names the listbox too.ariaNotify() on the List: by default the English "No results", "1 result", "N results". Nothing is announced while results are stale or loading (a Status fallback showing in the List), or for an unchanged result set. Localise with one function:<Combobox.Root announce={(n) => (n === 0 ? "該当なし" : `${n}件`)}>.listbox { margin: 0; inset: auto; max-block-size: 20rem; overflow: auto; }
.listbox [role="listbox"][data-stale] { opacity: .6; } /* old results while searching */
.listbox [role="option"][data-active] { background: var(--accent-weak); }
.listbox [role="option"][data-selected]::after { content: "✓"; }
.listbox [role="option"][data-pending] { cursor: progress; }
.combobox-input:user-invalid { border-color: var(--danger); }
/* Enter / exit for dismissals too (Escape, a click outside). */
.listbox { opacity: 0; transition: opacity 120ms, display 120ms allow-discrete, overlay 120ms allow-discrete; }
.listbox:popover-open { opacity: 1; }
@starting-style { .listbox:popover-open { opacity: 0; } }transition on the Popup animates the library's own opens and closes with a view transition in every engine (including the close after an accepted option, which shows the pending option until it fades). A results change is a deferred render, and React runs a view transition for it too, during which the page takes no pointer input. Unless every results change should cross-fade, give it a type map:
<Combobox.Popup
transition={{ "fsui-combobox-open": "pop", "fsui-combobox-close": "pop", default: "none" }}
>Types: every value dispatch adds fsui-combobox and fsui-combobox-select; opens and closes add fsui-combobox-open / fsui-combobox-close.
name on the Root posts the value through a hidden input ("" for null); a name on the Input posts the raw text as well. required on the Input is native.form.reset(), or React's automatic reset after a form action) dispatches valueChangeAction with reason: "platform" and writes the text: uncontrolled, back to the latest defaultValue / defaultInputValue; controlled, to the mount-time values.inputValueChangeAction with reason: "platform".dir="rtl". Logical position-area keywords resolve against the popover's containing block, the root, so dir="rtl" on a container alone does not flip them: set positionArea through style there.Menu.Popup a combobox is not supported (the menu's typeahead and focus handling compete with the input).style={{ positionTryFallbacks: "none" }} on Combobox.Popup.anchor-name and pass positionAnchor and minWidth: "anchor-size(--group width)" through style.