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}onInputSelectTrigger(not on theInputSelectFieldwrapper — that's a plaindiv; 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
reclaimIfUnclaimedabove, 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 unconditionalbuttonRef.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:
- 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.
- Already on the hidden trigger — the transitional state once that async move has landed.
- 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. - 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.