SelectMenu
The SelectMenu is an accessible and customisable dropdown menu that allows users to select an option from the list. In small screens (tablet and mobile), the SelectMenu will be displayed as a modal. Modal is responsive as well, so as a consequence, the SelectMenu will be displayed as a Sheet on mobile. The title for a modal or a sheet can be provided as a prop or can be inherited from the field label.
Quick Start
- Installation
npm install @adaptavant/eds-core- Import
import { SelectMenu } from '@adaptavant/eds-core';- Added
1.0.0
Basic Usage
Use SelectMenu to pick a single option from a list, without search. It also supports a custom trigger.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field
label="Animals"
>
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Size
Customise the size of the SelectMenu by using the size prop for the Field.
The default size is "standard".
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field
label="Animals"
// large | standard
size="large"
>
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Popover Customisations
The SelectMenu component provides several props to customise the appearance and behavior of its popover. These props allow for fine-grained control over the popover’s width, height, offset, and placement relative to the trigger element.
popoverMatchReferenceWidth: Set totrueto make the popover’s width match the trigger’s width, orfalsefor independent width.popoverMaxHeight: Sets the maximum height of the popover in pixels. The default is356.popoverMaxWidth: Sets the maximum width of the popover in pixels. The default is400.popoverOffset: Adjusts the space between the popover and the trigger, specified in pixels. The default is4.popoverPlacement: Determines the position of the popover relative to the trigger. Options include 'bottom', 'bottom-start', and 'bottom-end', allowing for flexible positioning based on the layout and space available. The default is 'bottom-start'. If there isn’t enough space for the popover to appear below the trigger, it will automatically switch to the top position.
Here’s how you can use these props in your SelectMenu component:
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Animals">
<SelectMenu
popoverMatchReferenceWidth={false} // boolean
popoverMaxHeight={180} // number
popoverMaxWidth={180} // number
popoverOffset={12} // number
popoverPlacement="bottom-start" // 'bottom' | 'bottom-start' | 'bottom-end'
>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal)
}}
>
{animal.value}
</SelectMenuItem>
)}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Custom Trigger
You can create a custom trigger for the SelectMenu by providing a callback function as the children prop. This function provides triggerProps, which is an object containing both the props required for the trigger to function, as well as other attributes required for it to be accessibly labelled. It also provides isMenuOpen, which is a boolean indicating the open state of the menu.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Animals">
<SelectMenu>
{({ triggerProps, isMenuOpen }) => (
<React.Fragment>
<Button
className="w-fit"
iconEnd={isMenuOpen ? DropdownUpIcon : DropdownDownIcon}
variant="neutralSecondary"
{...triggerProps}
>
{selectedOption ? (
<Emoji>{selectedOption.emoji}</Emoji>
) : (
<Text className="text-body-12">Select an animal</Text>
)}
</Button>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => setSelectedOption(animal)}
>
{animal.value}
</SelectMenuItem>
)}
</SelectMenuListbox>
</SelectMenuPopover>
</React.Fragment>
)}
</SelectMenu>
</Field>
);
Custom icon at end
You can also display a custom icon at the end of the select menu trigger by using the iconEnd prop.
const sortItems = [
{ id: '1', value: 'Sort Ascending', emoji: '⬆️' },
{ id: '2', value: 'Sort Descending', emoji: '⬇️' },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Sort order">
<SelectMenu>
<SelectMenuTrigger iconEnd={SortIcon} placeholder="Sort">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={sortItems}>
{(sortItem) => {
return (
<SelectMenuItem
id={sortItem.id}
isSelected={selectedOption?.id === sortItem.id}
onClick={() => {
setSelectedOption(sortItem);
}}
>
{sortItem.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Strategy
Use the strategy prop to change the way how popover element will be positioned. By default, the strategy is set to absolute, changes it to fixed when the SelectMenuTrigger is inside a sticky or fixed element.
This option leverages the floating-ui library, which powers the SelectMenuPopover functionality.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
const [showFixedElement, setShowFixedElement] = React.useState(false);
const onButtonClick = () => {
setShowFixedElement((prevState) => !prevState)
}
return (
<Stack className="w-full gap-4">
{showFixedElement ? (
<div className="
animate-[snackbar-transition_0.3s_cubic-bezier(0.16,_1,_0.3,_1)]
bg-neutral-secondary
fixed
flex
items-center
justify-between
mx-2
p-4
right-0
rounded-8px
shadow-40
sm:right-8
sm:w-[360px]
top-8
w-[calc(100%-16px)]
z-10
">
<Field label="Animals">
<SelectMenu strategy="fixed">
{({ triggerProps, isMenuOpen }) => (
<React.Fragment>
<Button
className="w-fit"
iconEnd={isMenuOpen ? DropdownUpIcon : DropdownDownIcon}
variant="neutralSecondary"
{...triggerProps}
>
{selectedOption ? (
<Emoji>{selectedOption.emoji}</Emoji>
) : (
<Text className="text-body-12">Select an animal</Text>
)}
</Button>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => setSelectedOption(animal)}
>
{animal.value}
</SelectMenuItem>
)}
</SelectMenuListbox>
</SelectMenuPopover>
</React.Fragment>
)}
</SelectMenu>
</Field>
<button
className="
focus-visible:focus-ring
font-stronger
px-1
py-0.5
rounded-4px
text-body-12
text-primary
underline
underline-offset-2
"
onClick={onButtonClick}
>
Close
</button>
</div>
) : null}
<Button onClick={onButtonClick}>
Show fixed element
</Button>
</Stack>
);
Disabled
Utilize the isDisabled prop in the <Field/> to show that a entire SelectMenu isn't usable.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field
label="Animals"
isDisabled
>
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Disabled MenuItem
Utilize the isDisabled prop in the <SelectMenuItem /> component to indicate that a specific item is not selectable.
This enhances user experience by clearly displaying the disabled state. Additionally, for improved accessibility, keyboard navigation will bypass disabled MenuItems in the Listbox.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘", disabled: true },
{ id: "2", value: "Lion", emoji: "🦁", disabled: false },
{ id: "3", value: "Tiger", emoji: "🐅", disabled: true },
{ id: "4", value: "Zebra", emoji: "🦓", disabled: false },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field
label="Animals"
>
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isDisabled={animal.disabled}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Note: Disabled item can already be selected option but having any interactions on it won't be possible.
Responsive layout
Utilize the closeButtonPropsForMobile? prop in the <SelectMenu /> component to make sure that in mobile friendly version of the SelectMenu the Modal has a close button.
You can also use the titleForMobile? prop to set the title of the Modal that appears on mobile instead of the Popover.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅"},
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Animals">
<SelectMenu titleForMobile="Choose an animal" closeButtonPropsForMobile={{
label: "Close animal select" }}>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem id={animal.id} isSelected={selectedOption?.id===animal.id} onClick={()=> {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Note: All SelectMenu components are mobile friendly by default.
Composing SelectMenuItem
You can customize the appearance and behavior of each menu item in SelectMenu by adding adornments or interactive components within it. For full details and composition patterns, see the MenuItem composition documentation.
1. SelectMenuItem with adornments
This example demonstrates how to add an icon or adornment to the start of each SelectMenuItem.
const locations = [
{ id: "1", value: "In-person", icon: TeamIcon },
{ id: "2", value: "Zoom", icon: ZoomColorIcon },
];
const [selectedOption, setSelectedOption] = React.useState(locations[0]);
return (
<Field label="Location">
<SelectMenu>
<SelectMenuTrigger>{selectedOption?.value}</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox>
<>
{locations.map((location) => {
return (
<SelectMenuItem
id={location.id}
isSelected={selectedOption?.id === location.id}
key={location.id}
onClick={() => {
setSelectedOption(location);
}}
>
<Track className="gap-1" railStart={<location.icon size="16" />}>{location.value}</Track>
</SelectMenuItem>
);
})}
</>
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
2. SelectMenuItem with toggle
In this example, the last SelectMenuItem contains a toggle in its railEnd slot. Interacting with the menu item containing the toggle only triggers the toggle action; it does not select the option or close the menu.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState(animals[0]);
const [toggleChecked, setToggleChecked] = React.useState(false);
return (
<Field label="Animals">
<SelectMenu>
<SelectMenuTrigger>{selectedOption?.value}</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox>
<>
{animals.map((animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
key={animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
})}
<SelectMenuItem
as="label"
className="text-body-12 gap-1"
htmlFor="toggle-comp"
id="listitem-with-toggle"
isSelected={selectedOption?.id === "listitem-with-toggle"}
onClick={() => {
return false;
}}
railEnd={
<TogglePrimitive
checked={toggleChecked}
id="toggle-comp"
onClick={(e) => {
e.stopPropagation();
setToggleChecked(!toggleChecked);
}}
tabIndex={-1}
/>
}
>
SelectMenu Item with toggle
</SelectMenuItem>
</>
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Without selection indicator
You can set the showSelectionIndicator to false on SelectMenuItem to hide the tick/checkmark that marks the selected item. The item still tracks its selected state — only the visual indicator is removed.
const animals = [
{ id: "1", value: "Elephant", emoji: "🐘" },
{ id: "2", value: "Lion", emoji: "🦁" },
{ id: "3", value: "Tiger", emoji: "🐅" },
{ id: "4", value: "Zebra", emoji: "🦓" },
];
const [selectedOption, setSelectedOption] = React.useState(animals[0]);
return (
<Field label="Animals" secondaryLabel="(without selection indicator)">
<SelectMenu>
<SelectMenuTrigger>{selectedOption?.value}</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
showSelectionIndicator={false}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Long list of options
For menus with many options, set the popoverMaxHeight prop on SelectMenu to limit the popover's height and the listbox becomes scrollable.
const animals = [
{ id: '1', value: 'Elephant', emoji: '🐘', isDisabled: true },
{ id: '2', value: 'Lion', emoji: '🦁', isDisabled: false },
{ id: '3', value: 'Tiger', emoji: '🐅', isDisabled: true },
{ id: '4', value: 'Zebra', emoji: '🦓', isDisabled: false },
{ id: '5', value: 'Giraffe', emoji: '🦒', isDisabled: true },
{ id: '6', value: 'Hippo', emoji: '🦛', isDisabled: false },
{ id: '7', value: 'Rhino', emoji: '🦏', isDisabled: true },
{ id: '8', value: 'Panda', emoji: '🐼', isDisabled: true },
{ id: '9', value: 'Kangaroo', emoji: '🦘', isDisabled: false },
{ id: '10', value: 'Penguin', emoji: '🐧', isDisabled: true },
{ id: '11', value: 'Polar Bear', emoji: '🐻', isDisabled: false },
{ id: '12', value: 'Dolphin', emoji: '🐬', isDisabled: true },
{ id: '13', value: 'Shark', emoji: '🦈', isDisabled: false },
{ id: '14', value: 'Octopus', emoji: '🐙', isDisabled: true },
{ id: '15', value: 'Crocodile', emoji: '🐊', isDisabled: false },
{ id: '16', value: 'Butterfly', emoji: '🦋', isDisabled: true },
{ id: '17', value: 'Fish', emoji: '🐟', isDisabled: false },
{ id: '18', value: 'Snake', emoji: '🐍', isDisabled: true },
{ id: '19', value: 'Honey Bee', emoji: '🐝', isDisabled: false },
{ id: '20', value: 'Bird', emoji: '🐦', isDisabled: true },
{ id: '21', value: 'Chicken', emoji: '🐓', isDisabled: false },
{ id: '22', value: 'Cow', emoji: '🐮', isDisabled: true },
{ id: '23', value: 'Dog', emoji: '🐕', isDisabled: false },
{ id: '24', value: 'Cat', emoji: '🐈', isDisabled: true },
{ id: '25', value: 'Donkey', emoji: '🐴', isDisabled: false },
];
const [selectedOption, setSelectedOption] = React.useState(animals[3]);
return (
<Field label="Animals">
<SelectMenu popoverMaxHeight={220}>
<SelectMenuTrigger>{selectedOption?.value}</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
FullScreen on Mobile
On mobile, enable fullScreenForMobile to render the sheet in full-screen with a default close button.
const animals = [
{ id: "1", value: "Elephant" },
{ id: "2", value: "Lion" },
{ id: "3", value: "Tiger" },
{ id: "4", value: "Zebra" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Animals">
<SelectMenu fullScreenForMobile>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
With Search
Use SelectMenuSearchField and SelectMenuSearchInput to add a search box inside the popover. The useFilteredOptions hook manages the search term and returns a filteredOptions array along with getSearchInputProps, which you spread onto the search input. Pass filteredOptions to SelectMenuListbox's options prop, and use noResultsFallback to render content when a search matches nothing. noResultsFallback only works with the options prop; see the next example for mapped children.
const animals = [
{ id: "1", value: "Elephant" },
{ id: "2", value: "Lion" },
{ id: "3", value: "Tiger" },
{ id: "4", value: "Zebra" },
{ id: "5", value: "Giraffe" },
{ id: "6", value: "Hippo" },
{ id: "7", value: "Rhino" },
{ id: "8", value: "Panda" },
{ id: "9", value: "Kangaroo" },
];
const [selectedOption, setSelectedOption] = React.useState();
const { filteredOptions, getSearchInputProps } = useFilteredOptions({
initialOptions: animals,
searchFunction: ({ options, searchTerm }) =>
options.filter((option) =>
option.value.toLowerCase().includes(searchTerm.toLowerCase())
),
});
return (
<Field label="Animals">
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuSearchField label="Search animals">
<SelectMenuSearchInput
{...getSearchInputProps()}
placeholder="Search..."
/>
</SelectMenuSearchField>
<SelectMenuListbox
noResultsFallback={
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
}
options={filteredOptions}
>
{(animal) => (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => setSelectedOption(animal)}
>
{animal.value}
</SelectMenuItem>
)}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Multi-select with search
Build a multi-select filter with search by rendering a Checkbox inside each SelectMenuItem. Returning false from an item's onClick keeps the menu open while options are toggled. A "Select all" item at the top of the listbox toggles every option and shows an indeterminate state when only some are selected. Set showSelectionIndicator={false} so the checkbox is the only selection cue.
Because "Select all" is an extra item that is not part of the data, the items are passed as mapped children instead of through the options prop. noResultsFallback does not apply to mapped children, so this example renders its own NoResults when filteredOptions is empty. Keep SelectMenuListbox mounted in that case so the search input's aria-controls still points to it.
const accountNumberList = [
{ id: 1, value: '812324324' },
{ id: 2, value: '811224324' },
{ id: 3, value: '678924324' },
{ id: 4, value: '678924324' },
{ id: 5, value: '812324325' },
{ id: 6, value: '811224326' },
{ id: 7, value: '678924327' },
{ id: 8, value: '812324328' },
{ id: 9, value: '811224329' },
{ id: 10, value: '678924330' },
{ id: 11, value: '812324331' },
{ id: 12, value: '811224332' },
];
const [checkedItems, setCheckedItems] = React.useState({});
const [searchTerm, setSearchTerm] = React.useState('');
const enabledItems = accountNumberList.map(({ id }) => id);
const allChecked = enabledItems.every((id) => checkedItems[id]);
const someChecked = enabledItems.some((id) => checkedItems[id]) && !allChecked;
const checkedCount = enabledItems.filter((id) => checkedItems[id]).length;
function onClear() {
return setSearchTerm('');
}
function handleInputOnChange(event) {
return setSearchTerm(event.target.value);
}
function handleItemChange(id) {
setCheckedItems((prevState) => ({
...prevState,
[id]: !prevState[id],
}));
}
function handleHeaderChange() {
const newCheckedState = !allChecked;
setCheckedItems((prevState) => {
const updatedState = { ...prevState };
enabledItems.forEach((id) => {
updatedState[id] = newCheckedState;
});
return updatedState;
});
}
const filteredOptions = searchTerm === "" ? accountNumberList : accountNumberList.filter((accountNumber) =>
accountNumber.value.toLowerCase().includes(searchTerm.toLowerCase())
);
function NoResults() {
return (
<Text className="text-secondary text-center text-body-12 py-4">
No matching results
</Text>
);
}
function triggerContent() {
if (checkedCount === 0) return 'Select number';
if (allChecked) return 'All numbers';
if (checkedCount === 1) {
const selected = accountNumberList.find((account) => checkedItems[account.id]);
return selected?.value;
}
return `${checkedCount} numbers`;
}
return (
<Field label="Account number">
<SelectMenu popoverMaxHeight={210}>
<SelectMenuTrigger placeholder="Select account number">
{triggerContent()}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuSearchField label="Search account number">
<SelectMenuSearchInput
onChange={handleInputOnChange}
onClear={onClear}
placeholder="Search..."
value={searchTerm}
/>
</SelectMenuSearchField>
<SelectMenuListbox>
{filteredOptions.length ? (
<SelectMenuItem
className="shrink-0"
showSelectionIndicator={false}
onClick={() => {
handleHeaderChange();
return false;
}}
>
<Checkbox
checked={allChecked}
indeterminate={someChecked}
label="Select all"
className="pointer-events-none"
/>
</SelectMenuItem>
) : null}
{filteredOptions.map((account) => (
<SelectMenuItem
key={account.id}
showSelectionIndicator={false}
onClick={() => {
handleItemChange(account.id);
return false;
}}
>
<Checkbox
checked={Boolean(checkedItems[account.id])}
id={account.id.toString()}
label={account.value}
className="pointer-events-none"
/>
</SelectMenuItem>
))}
</SelectMenuListbox>
{filteredOptions.length ? null : <NoResults />}
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Hierarchical menu
Set isHierarchical on SelectMenu and write every row as a SelectMenuTreeItem. Keyboard behaviour is the standard tree pattern.
A parent is selectable like any other row, since only the chevron expands. Use defaultExpandedIds to open a branch on first render.
const regions = [
{
id: 'emea',
name: 'EMEA',
children: [
{ id: 'uk', name: 'United Kingdom' },
{ id: 'pol', name: 'Poland' },
],
},
{
id: 'apac',
name: 'APAC',
children: [
{ id: 'ind', name: 'India' },
{ id: 'aus', name: 'Australia' },
],
},
{ id: 'global', name: 'Global' },
];
const [selectedId, setSelectedId] = React.useState();
const selectedName = regions
.flatMap((region) => [region, ...(region.children || [])])
.find((region) => region.id === selectedId)?.name;
function renderRegion(region) {
return (
<SelectMenuTreeItem
id={region.id}
isSelected={selectedId === region.id}
key={region.id}
label={region.name}
onClick={() => setSelectedId(region.id)}
>
{region.children?.map(renderRegion)}
</SelectMenuTreeItem>
);
}
return (
<Field label="Regions">
<SelectMenu defaultExpandedIds={['emea']} isHierarchical>
<SelectMenuTrigger placeholder="Select a region">
{selectedName}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox>{regions.map(renderRegion)}</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Hierarchical menu with cascading selection
When ticking a parent should tick everything under it, let useCascadingSelection do the arithmetic. Give it the tree you already render plus how to read an id and children, then pair getSelectionState with the row's selectionState and toggle with its onClick.
selectedIds comes back as the leaves the user picked, never a parent. Return false from onClick to keep the menu open between picks.
const serviceCatalogue = [
{
id: 'hair',
name: 'Hair',
children: [
{ id: 'haircut', name: 'Haircut' },
{ id: 'blow-dry', name: 'Blow dry' },
{ id: 'colour', name: 'Colour' },
],
},
{
id: 'nails',
name: 'Nails',
children: [
{ id: 'manicure', name: 'Manicure' },
{ id: 'pedicure', name: 'Pedicure' },
],
},
{ id: 'consultation', name: 'Consultation' },
];
const { getSelectionState, selectedIds, toggle } = useCascadingSelection({
items: serviceCatalogue,
getId: (service) => service.id,
getChildren: (service) => service.children,
});
function renderService(service) {
return (
<SelectMenuTreeItem
id={service.id}
key={service.id}
label={service.name}
onClick={() => {
toggle(service);
return false;
}}
selectionState={getSelectionState(service)}
>
{service.children?.map(renderService)}
</SelectMenuTreeItem>
);
}
return (
<Field label="Services">
<SelectMenu isHierarchical>
<SelectMenuTrigger placeholder="Select services">
{selectedIds.length ? `${selectedIds.length} selected` : null}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuListbox>
{serviceCatalogue.map(renderService)}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
Cascading selection with search
Search a hierarchy by pruning it rather than flattening it: keep a row if it matches or if anything beneath it matches, then open the branches that survived so the matches are on screen.
Set expandedIds once per search term and then leave expansion alone, so the chevron keeps working while a term is active. Wait for isStale to clear first, because filteredOptions lags the typed term by a render.
Pass the whole tree to useCascadingSelection and tell it which rows survived through isSelectable. Without that, ticking a parent mid-search also picks the services the filter is hiding.
A recursive tree doesn't use the options prop, so noResultsFallback never fires here — render your own empty state beside the listbox.
const serviceCatalogue = [
{
id: 'hair',
name: 'Hair',
children: [
{ id: 'haircut', name: 'Haircut' },
{ id: 'blow-dry', name: 'Blow dry' },
{ id: 'colour', name: 'Colour' },
],
},
{
id: 'nails',
name: 'Nails',
children: [
{ id: 'manicure', name: 'Manicure' },
{ id: 'pedicure', name: 'Pedicure' },
],
},
{ id: 'consultation', name: 'Consultation' },
];
function pruneService(service, term) {
if (service.name.toLowerCase().includes(term)) return service;
const children = (service.children || [])
.map((child) => pruneService(child, term))
.filter(Boolean);
return children.length ? { ...service, children } : undefined;
}
function collectIds(services, predicate) {
return services.flatMap((service) => [
...(predicate(service) ? [service.id] : []),
...collectIds(service.children || [], predicate),
]);
}
const [expandedIds, setExpandedIds] = React.useState([]);
const [openedForTerm, setOpenedForTerm] = React.useState('');
const { filteredOptions, getSearchInputProps, isStale, searchTerm } =
useFilteredOptions({
initialOptions: serviceCatalogue,
searchFunction: ({ options, searchTerm }) =>
searchTerm
? options
.map((service) => pruneService(service, searchTerm.toLowerCase()))
.filter(Boolean)
: options,
});
if (!isStale && searchTerm !== openedForTerm) {
setOpenedForTerm(searchTerm);
setExpandedIds(
searchTerm
? collectIds(filteredOptions, (service) => (service.children || []).length)
: []
);
}
const visibleIds = new Set(collectIds(filteredOptions, () => true));
const { getSelectionState, selectedIds, toggle } = useCascadingSelection({
items: serviceCatalogue,
getId: (service) => service.id,
getChildren: (service) => service.children,
isSelectable: (service) => visibleIds.has(service.id),
});
function renderService(service) {
return (
<SelectMenuTreeItem
id={service.id}
key={service.id}
label={service.name}
onClick={() => {
toggle(service);
return false;
}}
selectionState={getSelectionState(service)}
>
{service.children?.map(renderService)}
</SelectMenuTreeItem>
);
}
return (
<Field label="Services">
<SelectMenu
expandedIds={expandedIds}
isHierarchical
onExpandedChange={setExpandedIds}
>
<SelectMenuTrigger placeholder="Select services">
{selectedIds.length ? `${selectedIds.length} selected` : null}
</SelectMenuTrigger>
<SelectMenuPopover>
<SelectMenuSearchField label="Search services">
<SelectMenuSearchInput
{...getSearchInputProps()}
placeholder="Search..."
/>
</SelectMenuSearchField>
<SelectMenuListbox>
{filteredOptions.map(renderService)}
</SelectMenuListbox>
{filteredOptions.length === 0 ? (
<Text className="text-secondary text-center text-body-12 py-4">
No matching services
</Text>
) : null}
</SelectMenuPopover>
</SelectMenu>
</Field>
);
API Reference
SelectMenu
| Prop | Default | Description |
|---|---|---|
children | _ | ((menuState: { isMenuOpen: boolean; triggerProps: TriggerProps }) => ReactNode) | ReactNodeAccepts either a React node or a render function. The render function provides the menu's state (isMenuOpen) and accessibility/interaction props (triggerProps) for the trigger. |
closeButtonPropsForMobile? | _ | { label: string, onClick: () => void, size?: IconButtonProps['size'] }Props for the close button that appears on mobile. |
defaultExpandedIds? | _ | Array<string>Ids of the items expanded on first render, for a hierarchical menu whose expansion is left to the menu. Ignored once expandedIds is provided. |
expandedIds? | _ | Array<string>Ids of the expanded items. Providing this puts expansion into controlled mode, so the menu stops keeping its own copy and reports every request through onExpandedChange instead. |
isHierarchical? | false | booleanWhether the items form a hierarchy. Parent items can be expanded and collapsed, and the list is exposed as a tree of treeitem rows rather than a listbox of option rows, which is what ARIA requires for expandable rows. |
mobileFriendly? | true | booleanWhether the select menu should be mobile friendly. |
onExpandedChange? | _ | (expandedIds: Array<string>) => voidCalled with the next set of expanded ids after every expand or collapse, in both controlled and uncontrolled mode. |
titleForMobile? | _ | stringThe title of the Modal that appears on mobile instead of Popover. |
|
|
|
popoverMatchReferenceWidth? | false | booleanMatch the width of the popover with the reference element. |
popoverMaxHeight? | 356 | numberThe max height of the select menu popover. |
popoverMaxWidth? | 400 | numberThe max width of the select menu popover. |
popoverOffset? | 4 | numberThe offset of the select menu popover. |
popoverPlacement? | 'bottom-start' | 'bottom' | 'bottom-start' | 'bottom-end'The placement of the select menu popover in relation to the trigger. |
| _ |
|
strategy? | 'absolute' | 'absolute' | 'fixed'The strategy used to position the floating element. |
fullScreenForMobile | false | booleanEnables fullscreen mode for the mobile select menu, making it cover the entire viewport. |
SelectMenuTrigger
The SelectMenuTrigger component is a customizable trigger for a select menu, built using the Button component. It inherits some Button props and adds few more additional functionalities.
| Prop | Default | Description |
|---|---|---|
children? | _ | ReactNodeThe content to be displayed inside the button. |
form? | _ | stringSpecifies the form element the button is associated with. |
iconEnd? | _ | ReactNodeThe icon to display after the button children. |
iconStart? | _ | ReactNodeThe icon to display before the button children. |
isLoading? | false | booleanIf true, the button will display a loading indicator. |
isPressed? | false | booleanIf true, the button will be visually styled as pressed and the aria-pressed attribute will be set accordingly. |
loadingLabel? | _ | stringText to read out to assistive technologies when button is loading. |
onBlur? | _ | functionFunction to call when the button loses focus. |
onFocus? | _ | functionFunction to invoke when the button receives focus. |
onKeyDown? | _ | functionFunction to invoke when a key is pressed while the button is focused. |
placeholder? | _ | stringDisplays a placeholder text when selectedOption is not provided. |
SelectMenuPopover
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeContent of the select menu popover. |
shouldUsePortal? | true | booleanDetermines whether the popover should be rendered in a React Portal. If true, the popover will be rendered outside the DOM hierarchy of the parent component. |
SelectMenuListbox
The listbox takes no role; it reads isHierarchical from the menu, so it can never disagree with its rows about which ARIA contract applies.
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNode | ((option: Option) => ReactNode)Items to render inside the listbox. Must use SelectMenuItem or SelectMenuTreeItem as the item element. In a hierarchical menu, write every row as a SelectMenuTreeItem. Keyboard navigation (Arrow keys, typeahead) requires each item to register with FloatingUI via useListItem, which both components call internally. Arbitrary role="option" elements or custom item components that do not use one of them will not be reachable by keyboard. |
noResultsFallback? | _ | ReactNodeComponent to render when the options array is empty, for example when a search matches nothing. Requires the options prop. It never renders when items are passed as mapped children (for example filteredOptions.map(...)), because the listbox cannot tell those are empty. In that case, render your own empty state next to the listbox. |
options? | _ | Array of ObjectsOptions to be rendered in the popover. Required when children is a function. Each item in the array should have a unique id or key property for caching. |
SelectMenuItem
SelectMenuItem is the row of a flat menu. A hierarchical menu uses SelectMenuTreeItem for every row instead.
Selection is reported one of two ways, and a menu should pick one and keep to it: isSelected for an ordinary single-select menu, selectionState for a cascading one. They emit different ARIA, aria-selected against aria-checked, so mixing them inside one menu makes its rows announce themselves inconsistently.
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeThe content of the select menu item. |
id? | _ | stringAn optional ID for the menu item. If not provided, an ID will be automatically generated. |
isDisabled? | false | booleanIndicates if the menu item is currently disabled. Used for styling and accessibility. Applies an aria-disabled attribute. |
isHighlighted? | false | booleanIndicates if the menu item is currently highlighted. Used for styling. Applies a data-highlighted attribute. Note: Controlled internally to support keyboard navigations. |
isSelected? | false | booleanIndicates if the menu item is currently selected. Used for styling. Applies an aria-selected=true and data-selected=true otherwise aria-selected=false |
onClick? | _ | () => boolean | voidFunction to be invoked when the item is clicked. |
selectionState? | _ | 'checked' | 'partial' | 'unchecked'Selection state of an item in a hierarchical menu whose parents cascade their selection onto their children. 'partial' marks a parent whose children are only partly selected, which renders a mixed state indicator and reports aria-checked="mixed". Takes precedence over isSelected. Derive it with the useCascadingSelection hook rather than by hand. |
showSelectionIndicator? | true | booleanControls whether to show the selection indicator (check icon) for selected items. |
railEnd? | _ | ReactNodeThe content/components to appear in the item end. |
railStart? | _ | ReactNodeThe content/components to appear in the item start. |
|
|
|
verticalAlign? | 'middle' | 'bottom' | 'middle' | 'top'Determines how the rails and center are vertically aligned to each other. A typography or heading can be provided to center align icons and with text that may wrap. |
SelectMenuSearchField
The SelectMenuSearchField internally uses Field component. It inherits most of the Field props and adds few more functionalities.
| Prop | Default | Description |
|---|---|---|
children | _ | ReactNodeThe content to be displayed inside the field. |
label | _ | ReactNodeLabel for the input. |
| _ |
|
| _ |
|
| _ |
|
|
|
|
|
|
|
|
|
|
| _ |
|
SelectMenuSearchInput
The SelectMenuSearchInput component internally uses SearchInput component. It inherits most of the SearchInput props and adds few more functionalities.
| Prop | Default | Description |
|---|---|---|
| _ |
|
| _ |
|
| _ |
|
| _ |
|
| _ |
|
aria-haspopup? | _ | 'dialog' | 'grid' | 'listbox' | 'menu' | 'tree' | 'boolean'Indicates the availability of a popup related to the input. |
| _ |
|
| _ |
|
autoFocus? | _ | booleanAutomatically focuses the input element when it is rendered. |
defaultValue? | _ | stringSpecifies the initial value of the input for uncontrolled input. |
focusContainerRef? | _ | React.Ref<HTMLDivElement>Reference to the wrapper FocusContainer element. |
inputMode? | _ | 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'Hints to browsers about what kind of virtual keyboard to display. |
| _ |
|
onBlur? | _ | functionFunction to be invoked when the input loses focus. |
onChange? | _ | functionFunction to be invoked when a new item is selected. |
onFocus? | _ | functionFunction to be invoked when the input is focused. |
onKeyDown? | _ | functionFunction to be invoked when a key is pressed while the input is focused. |
onPaste? | _ | functionFunction to be invoked when content is pasted into the input. |
pattern? | _ | stringRegular expression pattern the input's value must match for validation. |
placeholder? | _ | stringPlaceholder text displayed when the input is empty. |
| _ |
|
| _ |
|
type? | _ | 'email' | 'password' | 'search' | 'tel' | 'text' | 'url'Type of the input element. |
value? | _ | stringSpecifies the current value for controlled inputs. |
SelectMenuTreeItem
SelectMenuTreeItem is the row of a hierarchical menu. Use it for every row, whether or not it has children, so one recursive renderer covers the whole tree and a row that later gains children needs no rewrite.
label is always the row's own content and children is always the rows nested under it. A row with no children is written with a label and nothing else, and renders as an ordinary row with no disclosure.
Nested rows are siblings in the DOM rather than descendants, so nesting the JSX does not nest the markup. Depth and parentage come from where a row is rendered.
Set isHierarchical on the menu. Without it a row never expands and its children are not rendered at all, because the disclosure reports through aria-expanded, which is not allowed on option.
| Prop | Default | Description |
|---|---|---|
children? | _ | ReactNodeThe items nested under this one, rendered as indented rows directly below it while it is expanded. Omitting them renders an ordinary row with no disclosure, so a recursive renderer can pass node.children?.map(render) without first checking whether the node has any. |
label | _ | ReactNodeThis row's own content. Takes the place of children on SelectMenuItem. |
Everything else is inherited from SelectMenuItem, including id, isSelected, isDisabled, onClick, selectionState, and railEnd, except railStart. The row builds its start rail out of the indent and the selection indicator, so it does not accept one.
Style API
Our design system components include style props that allow you to easily customize different parts of each component to match your design needs.
Please refer to the Style API documentation for more insights.
Mobile Sheet Style API parts
const animals = [
{ id: "1", value: "Elephant" },
{ id: "2", value: "Lion" },
{ id: "3", value: "Tiger" },
{ id: "4", value: "Zebra" },
];
const [selectedOption, setSelectedOption] = React.useState();
return (
<Field label="Animals">
<SelectMenu>
<SelectMenuTrigger placeholder="Select an animal">
{selectedOption?.value}
</SelectMenuTrigger>
<SelectMenuPopover
classNames={{
sheet: "bg-positive",
sheetWrapper: "bg-caution",
sheetHeader: "bg-accent-inverse",
sheetContent: "bg-critical",
}}
>
<SelectMenuListbox options={animals}>
{(animal) => {
return (
<SelectMenuItem
id={animal.id}
isSelected={selectedOption?.id === animal.id}
onClick={() => {
setSelectedOption(animal);
}}
>
{animal.value}
</SelectMenuItem>
);
}}
</SelectMenuListbox>
</SelectMenuPopover>
</SelectMenu>
</Field>
);
| Stylable Parts | Description |
|---|---|
sheet | The Inner component wrapping the header, content, footer |
sheetWrapper | The overlay component. |
sheetHeader | The Header component in Sheet. |
sheetContent | The children components in Sheet. |
sheetFooter | The footer component in Sheet. |
closeButton | The closeButton in Sheet. |