FUNSTACK UI

HomeGetting startedConceptsComponentsRoadmap
GitHub

FUNSTACK UI is experimental. Part of the FUNSTACK family with FUNSTACK Router and FUNSTACK Static, which build this site.

Introduction

  • Overview
  • Getting started

Concepts

  • Action props
  • Pending & optimistic state
  • Transitions & ViewTransition
  • Suspense
  • Server Components
  • Browser-owned state
  • Experimental features

Components

  • Toggle
  • Tabs
  • Accordion
  • Tooltip
  • Dialog
  • Popover
  • Menu
  • Switch
  • Checkbox
  • RadioGroup
  • CheckboxGroup
  • Select
  • Combobox
  • Toast
  • Slider
  • Field & Form

Resources

  • Roadmap & changelog
  • GitHub ↗

Combobox

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

saved: null
Show code
ComboboxCountry.tsx
"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.css
/* 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;
  }
}

Typing is urgent; everything it causes is not

"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:

  • The results render from 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".
  • Opening is a transition (openChangeAction).
  • Choosing an option is a transition (valueChangeAction) with an optimistic selection, exactly as every other value in the library.

Do not fetch in inputValueChangeAction

inputValueChangeAction={async (q) => setResults(await search(q))} brings back every problem the deferred query solves: responses land in completion order, nothing marks the results stale, and the urgent action is not awaited anyway (development warns when it returns a promise). Read a query-keyed cache with use() inside the List instead.

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.

Anatomy

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>
  • Two states. 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).
  • Popup and List are separate because a listbox may contain only options and groups: 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.
  • No portal, no positioner. The Popup is shown with 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.
  • The Trigger has no 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.

Accepting an option

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.

Free text

text: "", submitted: null
Show code
ComboboxDrink.tsx
"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.css
/* 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.

Props

Combobox.Root

PropTypeDefaultDescription
valuestring | null–Controlled selection. null: nothing selected. undefined means uncontrolled.
defaultValuestring | nullnullInitial selection when uncontrolled; what a form reset returns to.
valueChangeActionChangeAction<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.
inputValuestring–Controlled text. undefined means uncontrolled.
defaultInputValuestring""Initial text when uncontrolled, and the visible input's form-reset target. Pass the selected option's label together with defaultValue.
inputValueChangeActionUrgentChangeAction<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.
openboolean–Controlled open state. undefined means uncontrolled.
defaultOpenbooleanfalseInitial open state; opens after hydration.
openChangeActionChangeAction<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).
allowCustomValuebooleanfalseFree text is a value (Enter with no active option, focus leaving), dispatched with details.custom: true. Otherwise unmatched text reverts when focus leaves.
autoHighlightbooleanfalseMake 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.
openOnClickbooleanfalseOpen on a click in the input (never on focus alone).
loopbooleanfalseArrowDown / 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.
namestring–Renders <input type="hidden" name> holding the optimistic value ("" for null), the Root's only element.
formstring–The hidden input's form attribute.
disabledbooleanfalseDisables 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).
childrenReactNode | ((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

PropTypeDefaultDescription
requiredboolean–Native: an empty text is valueMissing, with the browser's message.
classNamestring | ((state: ComboboxInputState) => string | undefined)–A class name, or a function of the input's state.
styleCSSProperties | ((state: ComboboxInputState) => CSSProperties | undefined)–Inline style, or a function of the input's state.
renderReactElement | ((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

PropTypeDefaultDescription
disabledbooleanfalseNative disabled; the Root's disabled applies too.
classNamestring | ((state: ComboboxTriggerState | ComboboxClearState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ComboboxTriggerState | ComboboxClearState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ComboboxTriggerState | ComboboxClearState) => ReactNode)–Content, usually an icon; give the button an aria-label (development warns without a name).
renderReactElement | ((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

PropTypeDefaultDescription
keepMountedbooleanfalsefalse: the children render only while open. true: they stay mounted in a hidden <Activity>.
transitionViewTransitionClass–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.
classNamestring | ((state: ComboboxPopupState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ComboboxPopupState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ComboboxPopupState) => ReactNode)–Content, or a function of the part's state.
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: ComboboxListState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ComboboxListState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ComboboxListState) => ReactNode)–Usually a function of { query } returning the Options for it, under your own <Suspense> for an async search.
renderReactElement | ((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

PropTypeDefaultDescription
valuestring–Required and unique within the Combobox; the option's id is derived from it, so it keeps its id across result sets.
labelstring–The text written into the input when the option is accepted. Default: the option's trimmed text at that moment. Not used for matching.
disabledbooleanfalsearia-disabled: shown, skipped by the arrow keys, never accepted.
classNamestring | ((state: ComboboxOptionState) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: ComboboxOptionState) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: ComboboxOptionState) => ReactNode)–Content, or a function of the option's state; must not suspend.
renderReactElement | ((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

PropTypeDefaultDescription
classNamestring | ((state: {} (Empty: ComboboxListState)) => string | undefined)–A class name, or a function of the part's state.
styleCSSProperties | ((state: {} (Empty: ComboboxListState)) => CSSProperties | undefined)–Inline style, or a function of the part's state.
childrenReactNode | ((state: {} (Empty: ComboboxListState)) => ReactNode)–Content. Empty renders only when the List has no options, the results are not stale and none are loading.
renderReactElement | ((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.

Data attributes

AttributeOnMeaning
data-openInput, TriggerOptimistic open state (aria-expanded).
data-pendingInputA selection or an open is in flight (the Root's pending).
data-pendingTriggerThe open (or close) is in flight.
data-pendingOptionThe option the in-flight selection is choosing.
data-selectedInputA value is selected (optimistic, not null).
data-selectedOptionThis option is the optimistic selection.
data-activeOptionaria-activedescendant points here (keyboard or hover).
data-staleInput, Popup, ListThe results shown are for an older text; the List also has aria-busy.
data-emptyListSettled with no options.
data-disabledInput, Trigger, Clear, OptionDisabled.
:popover-openPopupThe listbox is shown (browser-owned, no attribute).

Keyboard

KeyListboxBehaviour
Typing, Backspace, pasteanyChanges the text (urgent), clears the active option and opens the listbox.
ArrowDown / ArrowUpclosedOpens with the first / last enabled option active.
ArrowDown / ArrowUpopenNext / previous enabled option; stops at the ends (wraps with loop).
Alt+ArrowDownclosedOpens with no option active.
Alt+ArrowUpopenAccepts the active option, or closes.
EnteropenAccepts the active option; with none, commits free text (allowCustomValue) or does nothing.
EnterclosedNot handled (a form submits); commits changed free text first with allowCustomValue.
EscapeopenThe platform closes the listbox; focus and text stay. A second Escape reaches an enclosing Dialog or Popover.
Tab / Shift+TabanyFocus moves on and the listbox closes; the text reverts or, with allowCustomValue, commits. Tab never accepts the active option.
Home / End, Left / RightanyThe 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.

Accessibility and announcements

  • The Input is 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.
  • When results settle, the library announces their count with 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}件`)}>

Styling and ViewTransition

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

Forms

  • 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.
  • A form reset (the Reset button, 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.
  • Text typed before hydration (and Firefox's form restoration) is kept and reported once through inputValueChangeAction with reason: "platform".

Placement, nesting and browser notes

  • The listbox follows the page's direction (logical keywords) when the whole page is 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.
  • Inside a Dialog or a Popover the listbox nests in the top layer: the first Escape closes the listbox, the next the outer overlay; an outside click closes the stack. Inside a Menu.Popup a combobox is not supported (the menu's typeahead and focus handling compete with the input).
  • Safari: a WebKit bug (302008) can hang a page that opens a popover with position fallbacks inside another popover. If a Combobox inside a Popover or Dialog misbehaves in Safari, turn the flip off: style={{ positionTryFallbacks: "none" }} on Combobox.Popup.
  • Want the listbox as wide as an input-plus-buttons group? Give the group an anchor-name and pass positionAnchor and minWidth: "anchor-size(--group width)" through style.