Acronis UIKit
Components

InputSelect

A select field with label, options, sections, search, and status states.

Usage

import {
  InputSelect,
  InputSelectField,
  InputSelectLabel,
  InputSelectTrigger,
  InputSelectValue,
  InputSelectContent,
  InputSelectSearch,
  InputSelectGroup,
  InputSelectSection,
  InputSelectSectionLabel,
  InputSelectItem,
  InputSelectExpander,
  InputSelectDescription,
  InputSelectError,
  InputSelectStatus,
  useInputSelectFilter,
} from '@acronis-platform/ui-react';

InputSelect is a compound select built on Base UI's Select primitive — keyboard navigation, typeahead, focus management, and ARIA come from Base UI. InputSelect owns the value; InputSelectField wraps the label/trigger/ description/error furniture, and InputSelectContent holds the InputSelectItem options (optionally grouped into InputSelectSections, with an InputSelectSearch filter). Themed by the --ui-input-select-* tokens (the dropdown container also has an opt-in popover-styled look — see isPopoverStyled below). InputSelectField's className targets that wrapper and has no w-full, so it shrinks to its min-width inside a constrained flex/grid ancestor instead of being force-stretched.

Examples

A labeled select with a description:

<InputSelect items={fruitItems}>
  <InputSelectField>
    <InputSelectLabel>Fruit</InputSelectLabel>
    <InputSelectTrigger>
      <InputSelectValue placeholder="Select an option" />
    </InputSelectTrigger>
    <InputSelectDescription>Pick your favourite</InputSelectDescription>
  </InputSelectField>
  <InputSelectContent>
    <InputSelectItem value="apple">Apple</InputSelectItem>
    <InputSelectItem value="banana">Banana</InputSelectItem>
    <InputSelectItem value="grapes">Grapes</InputSelectItem>
  </InputSelectContent>
</InputSelect>

Required, with an error message:

<InputSelect items={fruitItems}>
  <InputSelectField>
    <InputSelectLabel required>Fruit</InputSelectLabel>
    <InputSelectTrigger>
      <InputSelectValue placeholder="Select an option" />
    </InputSelectTrigger>
    <InputSelectError>Please choose a fruit</InputSelectError>
  </InputSelectField>
  <InputSelectContent>{/* …items… */}</InputSelectContent>
</InputSelect>

A searchable, sectioned list:

<InputSelectContent>
  <InputSelectSearch aria-label="Filter" placeholder="Search" />
  <InputSelectSection>
    <InputSelectSectionLabel>Fruits</InputSelectSectionLabel>
    <InputSelectItem value="apple">Apple</InputSelectItem>
    <InputSelectItem value="banana">Banana</InputSelectItem>
  </InputSelectSection>
</InputSelectContent>

A tree / hierarchy, with icons and indentation. InputSelectExpander is a non-selectable row that expands or collapses a group; InputSelectItem's indent reserves the leading nesting spacer (levels 1–3 → 16 / 40 / 64 px) and icon renders a leading glyph:

<InputSelectContent>
  <InputSelectExpander
    expanded={open}
    onToggle={() => setOpen((o) => !o)}
    icon={<BriefcaseIcon size={16} />}
  >
    DataBridge Systems
  </InputSelectExpander>
  <InputSelectItem value="eu" indent={2} hidden={!open}>
    EU region
  </InputSelectItem>
</InputSelectContent>

Keep collapsed children mounted and toggle hidden (rather than unmounting them) so Base UI's selection-by-index stays stable. When a search query is active, flat InputSelectItems auto-hide against it; a tree that computes its own visibility reads the live query with useInputSelectFilter(). Pass textValue on an item whose children aren't plain text so it can still be matched, and value/onChange on InputSelectSearch to control the query externally.

A loading / empty / error status inside the content:

<InputSelectContent>
  <InputSelectStatus
    variant="error"
    action={<Button variant="ghost">Try again</Button>}
  >
    Couldn't load options
  </InputSelectStatus>
</InputSelectContent>

API Reference

InputSelectStatus

Prop

Type

The other parts (InputSelect, InputSelectField, InputSelectLabel, InputSelectTrigger, InputSelectValue, InputSelectContent, InputSelectSearch, InputSelectGroup, InputSelectSection, InputSelectSectionLabel, InputSelectDescription, InputSelectError) extend the corresponding Base UI Select primitive props. InputSelect accepts items, defaultValue / value, and Base UI Select root props. See the Base UI Select docs for the full prop surface.

InputSelectContent forwards side, align, sideOffset, alignOffset, collisionAvoidance, and anchor to the underlying Base UI Select.Positioner (and portalContainer to Select.Portal), for consumers who need to place the dropdown themselves. By default the popup opens below the trigger (side="bottom", align="start", sideOffset={4}) and Base UI flips or shifts it to stay in view when space is tight. To pin it to one placement instead — "always to the right, no matter what" — pass an explicit side/align together with collisionAvoidance:

<InputSelectContent
  side="right"
  sideOffset={20}
  collisionAvoidance={{ side: 'none', fallbackAxisSide: 'none' }}
>
  {/* …items… */}
</InputSelectContent>

side: 'none' stops the popup flipping to the opposite side and fallbackAxisSide: 'none' stops it falling back to the perpendicular axis, so it stays to the right even when it overflows the viewport — make sure the surrounding layout actually has room. alignOffset nudges the popup along the alignment axis (perpendicular to side) without changing align. portalContainer scopes the portal, which matters when the field renders inside a shadow root — see Shadow DOM Integration.

anchor points the popup at a different element than the trigger. The popup normally tracks InputSelectTrigger, which is what you want for a form field. When the control the user actually clicks is somewhere else — an external button driving a controlled open, with the trigger kept mounted but visually hidden — the trigger's position in flow is no longer where the popup should appear, so pass the visible element's ref instead:

function TenantPicker() {
  const [open, setOpen] = useState(false);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const popupRef = useRef<HTMLDivElement>(null);

  return (
    <>
      <Button
        ref={buttonRef}
        aria-expanded={open}
        onClick={() => setOpen((isOpen) => !isOpen)}
      >
        Select a tenant
      </Button>
      <InputSelect
        items={tenantItems}
        open={open}
        onOpenChange={(nextOpen, eventDetails) => {
          // Base UI reads the button's own pointerdown as an outside press and
          // closes the popup, which the button's onClick would then reopen.
          // Cancel that one close so the button stays the single toggle.
          if (
            !nextOpen &&
            eventDetails.reason === 'outside-press' &&
            buttonRef.current?.contains(eventDetails.event.target as Node)
          ) {
            eventDetails.cancel();
            return;
          }
          setOpen(nextOpen);
          if (!nextOpen) {
            // The Select's own trigger is `sr-only`, so focus must never be left
            // on it. At close time focus is either still inside the popup that's
            // going away (Escape / item selection — Base UI moves it to the
            // hidden trigger asynchronously, after this handler returns), already
            // on that hidden trigger, lost to `document.body` (an outside press
            // onto nothing focusable — Base UI doesn't move focus on this path),
            // or on another element the press legitimately focused. Reclaim it
            // for the visible button in the first three cases only.
            const reclaimIfUnclaimed = () => {
              const active = document.activeElement;
              if (
                active === triggerRef.current ||
                active === document.body ||
                (active !== null && popupRef.current?.contains(active))
              ) {
                buttonRef.current?.focus();
              }
            };
            reclaimIfUnclaimed();
            requestAnimationFrame(reclaimIfUnclaimed);
          }
        }}
      >
        <InputSelectField className="sr-only">
          <InputSelectLabel>Tenant</InputSelectLabel>
          <InputSelectTrigger ref={triggerRef} tabIndex={-1}>
            <InputSelectValue placeholder="Select a tenant" />
          </InputSelectTrigger>
        </InputSelectField>
        <InputSelectContent ref={popupRef} anchor={buttonRef} isPopoverStyled>
          {/* …items… */}
        </InputSelectContent>
      </InputSelect>
    </>
  );
}

The onOpenChange guard is what makes the external button work as a toggle. Without it, pressing the button while the popup is open closes it (Base UI sees the press as an outside press) and the button's onClick immediately reopens it, so the popup can never be dismissed from the button. Cancelling only that specific close — reason === 'outside-press' and the press landed inside the button — leaves the button's onClick as the single source of truth for open; a press anywhere else still dismisses the popup normally.

Keep the hidden trigger in the DOM (sr-only, not display: none): Base UI needs it for the combobox semantics. Base UI keeps aria-expanded, aria-controls, and the listbox relationship on that trigger and cannot move them, so give the external button its own accessible name (visible text or aria-label) and its own aria-expanded={open} — it's the control the user operates, and it already owns the open state. If you also want aria-controls on the button, pass an explicit id to InputSelectContent and reference it while the popup is open.

Because that trigger stays focusable while being visually hidden, focus management is on you — and both halves of it are required:

  • tabIndex={-1} on InputSelectTrigger (not on the InputSelectField wrapper — that's a plain div; the focusable node is the trigger button) takes the invisible trigger out of the natural Tab sequence, so tabbing through the page can't land on a control the user can't see.
  • Reclaiming focus for the external button on close — the guarded reclaimIfUnclaimed above, run twice. tabIndex={-1} does not block a programmatic .focus(), so on the paths where Base UI focuses the hidden trigger, closing the popup would otherwise park keyboard focus on an invisible node — a WCAG 2.4.7 Focus Visible failure. But the reclaim must be conditional: an unconditional buttonRef.current?.focus() steals focus from an element the user deliberately clicked.

Why the guard, and why two calls. When onOpenChange reports the close, focus is in one of four places:

  1. Still inside the popup that's about to unmount. This is the case for both Escape and item selection. Base UI moves focus from there to its own hidden trigger, but it does so asynchronously — after your handler has returned. Escape is not an exception; at synchronous handler time focus is still on the search input or the option row.
  2. Already on the hidden trigger — the transitional state once that async move has landed.
  3. Lost to document.body — an outside press onto nothing focusable, e.g. a blank area of the page. Base UI does not move focus to its own trigger on this path at all, so nothing will fix it for you.
  4. On another element the press legitimately focused — an outside press onto a different focusable control, e.g. an input elsewhere on the page. Base UI reports this as a single close, reason: 'outside-press', while focus is still inside the popup.

Cases 1–3 all mean "no one else has claimed focus" — the visible button should take it. Case 4 means the user chose where focus goes, and the end state must be the element they pressed. That's why the guard tests three conditions rather than just "trigger or body": checking only the trigger and document.body would miss case 1, where focus is still inside the closing popup. That popupRef.current?.contains branch is what carries Escape (it matches on the synchronous call, and the requestAnimationFrame call then finds the button already focused and no-ops) and item selection (focus is still on the option row at both the synchronous and the requestAnimationFrame call, so both match — but on this path the platform's own move to the hidden trigger can still land between the two calls, so it's the deferred reclaim that actually delivers the final focus, not a redundant no-op).

Case 4 is worth being precise about, because the guard does not detect it. At synchronous handler time focus is still on the popup's search input, so the contains branch matches and buttonRef.current?.focus() does fire. The end state is still correct — focus lands on the element the user pressed — because the browser's own native focus shift to the pressed element happens after that synchronous call and overrides it; the requestAnimationFrame call then sees the pressed element focused and no-ops. So the guard's role on this path is to stay out of the way on the deferred pass, not to recognize and skip the case up front.

So tabIndex={-1} fixes the Tab-order case and the guarded reclaim fixes the close-return case; neither half alone is sufficient. Put the reclaim after setOpen(nextOpen) in the same onOpenChange handler — the cancelled outside-press branch returns early and never reaches it, which is correct: that close never happens, so focus shouldn't move.

isPopoverStyled renders the dropdown with Popover's chrome instead of the default dropdown chrome: the --ui-popover-container-* fill / border / radius, no drop shadow, and a fade-zoom-slide animation on open and close. Everything inside the popup (search, sections, items, status rows, the anchor-width sizing) is unchanged. Use it when the dropdown reads as a floating menu opened from a page control rather than as a form field's option list, so it matches the surrounding Popover surfaces.

InputSelectItem extends the Base UI Select.Item props and adds icon, indent, and textValue. InputSelectExpander is a plain button (not a Select.Item) with expanded, onToggle, icon, and indent. useInputSelectFilter() returns the live { query, setQuery } and must be called inside an InputSelect.

Edit on GitHub

On this page