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.

, Select an animal
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".

, Select an animal
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 to true to make the popover’s width match the trigger’s width, or false for independent width.
  • popoverMaxHeight: Sets the maximum height of the popover in pixels. The default is 356.
  • popoverMaxWidth: Sets the maximum width of the popover in pixels. The default is 400.
  • popoverOffset: Adjusts the space between the popover and the trigger, specified in pixels. The default is 4.
  • 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:

, Select an animal
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.

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

, Select an animal
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.

, Select an animal
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.

, Select an animal
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.

, In-person
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.

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

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

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

, Select an animal
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.

, Select an animal
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.

, Select number
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.

, Select a region
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.

, Select services
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.

, Select services
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

PropDefaultDescription
children_((menuState: { isMenuOpen: boolean; triggerProps: TriggerProps }) => ReactNode) | ReactNode
Accepts 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?falseboolean
Whether 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?trueboolean
Whether the select menu should be mobile friendly.
onExpandedChange?_(expandedIds: Array<string>) => void
Called with the next set of expanded ids after every expand or collapse, in both controlled and uncontrolled mode.
titleForMobile?_string
The title of the Modal that appears on mobile instead of Popover.

togglePoint?

767

number
The toggle point for the select menu to switch to mobile view.

⚠️ Deprecated with backwards compatibility. The logic will be handled internally in future.

popoverMatchReferenceWidth?falseboolean
Match the width of the popover with the reference element.
popoverMaxHeight?356number
The max height of the select menu popover.
popoverMaxWidth?400number
The max width of the select menu popover.
popoverOffset?4number
The 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.

selectedOption?

_

object
The currently selected option.

⚠️ Deprecated and will be removed in future versions. Use isSelected on SelectMenuItem instead.

strategy?'absolute''absolute' | 'fixed'
The strategy used to position the floating element.
fullScreenForMobilefalseboolean
Enables 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.

PropDefaultDescription
children?_ReactNode
The content to be displayed inside the button.
form?_string
Specifies the form element the button is associated with.
iconEnd?_ReactNode
The icon to display after the button children.
iconStart?_ReactNode
The icon to display before the button children.
isLoading?falseboolean
If true, the button will display a loading indicator.
isPressed?falseboolean
If true, the button will be visually styled as pressed and the aria-pressed attribute will be set accordingly.
loadingLabel?_string
Text to read out to assistive technologies when button is loading.
onBlur?_function
Function to call when the button loses focus.
onFocus?_function
Function to invoke when the button receives focus.
onKeyDown?_function
Function to invoke when a key is pressed while the button is focused.
placeholder?_string
Displays a placeholder text when selectedOption is not provided.

SelectMenuPopover

PropDefaultDescription
children_ReactNode
Content of the select menu popover.
shouldUsePortal?trueboolean
Determines 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.

PropDefaultDescription
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?_ReactNode
Component 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 Objects
Options 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.

PropDefaultDescription
children_ReactNode
The content of the select menu item.
id?_string
An optional ID for the menu item. If not provided, an ID will be automatically generated.
isDisabled?falseboolean
Indicates if the menu item is currently disabled. Used for styling and accessibility. Applies an aria-disabled attribute.
isHighlighted?falseboolean
Indicates if the menu item is currently highlighted. Used for styling. Applies a data-highlighted attribute. Note: Controlled internally to support keyboard navigations.
isSelected?falseboolean
Indicates 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 | void
Function 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?trueboolean
Controls whether to show the selection indicator (check icon) for selected items.
railEnd?_ReactNode
The content/components to appear in the item end.
railStart?_ReactNode
The content/components to appear in the item start.

size?

'standard'

'standard' | 'large'
The size of the dropdown menu item.

⚠️ Deprecated, the size is now automatically determined based on the Field context.

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.

PropDefaultDescription
children_ReactNode
The content to be displayed inside the field.
label_ReactNode
Label for the input.

counter?

_

string
Counter object containing maxValue and value. "maxValue:" maximum value the counter can reach. "value:" current value of the counter.

⚠️ Deprecated, SelectMenuSearchField search inputs don't need character counters as they're typically used for textarea.

description?

_

ReactNode
Provide additional information that will aid user input.

⚠️ Deprecated, SelectMenuSearchField is a simplified search field that doesn't need description.

errorMessage?

_

ReactNode
Message to show when the field is invalid.

⚠️ Deprecated, SelectMenuSearchField doesn't require validation, so error messages are unnecessary.

isDisabled?

false

boolean
Whether the field is disabled.

⚠️ Deprecated, SelectMenuSearchField doesn't need disabling the search input in SelectMenu component.

isRequired?

false

boolean
Whether user input is required on the field.

⚠️ Deprecated, SelectMenuSearchField doesn't require validation, so isRequired is unnecessary.

labelVisibility?

'hidden'

'visible' | 'hidden'
A label must always be provided for assistive technology, but you may hide it from sighted users when the intent can be inferred from context.

⚠️ Deprecated, SelectMenuSearchField internally manages label visibility and sets it to hidden by default.

secondaryLabel?

_

ReactNode
Additional context, typically used to indicate that the field is optional.

⚠️ Deprecated, SelectMenuSearchField is a simplified search field that doesn't need secondary labels.

SelectMenuSearchInput

The SelectMenuSearchInput component internally uses SearchInput component. It inherits most of the SearchInput props and adds few more functionalities.

PropDefaultDescription

aria-activedescendant?

_

string
identifies the currently active element when using ARIA widgets.

⚠️ Deprecated, SelectMenuSearchInput internally sets aria-activedescendant based on device type and selected item.

aria-autocomplete?

_

'inline' | 'list' | 'both' | 'none'
Indicates whether input completion suggestions are provided, and their type.

⚠️ Deprecated, SelectMenuSearchInput internally sets aria-autocomplete to 'list' for proper accessibility.

aria-controls?

_

string
Identifies the element(s) that the input controls.

⚠️ Deprecated, SelectMenuSearchInput internally sets aria-controls to the listId from dropdown context.

aria-expanded?

_

boolean
Indicates whether the popover is currently expanded or collapsed.

⚠️ Deprecated, SelectMenuSearchInput internally sets aria-expanded based on menu open state from context.

disabled?

_

boolean
Disables the search input.

⚠️ Deprecated, SelectMenuSearchInput doesn't need disabling the search input in SelectMenu component.

aria-haspopup?_'dialog' | 'grid' | 'listbox' | 'menu' | 'tree' | 'boolean'
Indicates the availability of a popup related to the input.

autoComplete?

_

string
Specifies whether autocomplete is enabled for the input.

⚠️ Deprecated, SelectMenuSearchInput internally sets autoComplete to 'off' to prevent browser suggestions.

autoCorrect?

_

'on' | 'off'
Specifies whether auto-correction is enabled for the input.

⚠️ Deprecated, SelectMenuSearchInput internally sets autoCorrect to 'off' to prevent text correction.

autoFocus?_boolean
Automatically focuses the input element when it is rendered.
defaultValue?_string
Specifies 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.

name?

_

string
Name of the input, used for form submissions.

⚠️ Deprecated, SelectMenuSearchInput doesn't need form association, so name attribute is unnecessary.

onBlur?_function
Function to be invoked when the input loses focus.
onChange?_function
Function to be invoked when a new item is selected.
onFocus?_function
Function to be invoked when the input is focused.
onKeyDown?_function
Function to be invoked when a key is pressed while the input is focused.
onPaste?_function
Function to be invoked when content is pasted into the input.
pattern?_string
Regular expression pattern the input's value must match for validation.
placeholder?_string
Placeholder text displayed when the input is empty.

role?

_

string
ARIA role for the input.

⚠️ Deprecated, SelectMenuSearchInput internally sets role to 'combobox' for proper accessibility.

spellCheck?

_

boolean
Specifies whether the input value should be checked for spelling errors.

⚠️ Deprecated, SelectMenuSearchInput internally sets spellCheck to 'false' to prevent spell checking.

type?_'email' | 'password' | 'search' | 'tel' | 'text' | 'url'
Type of the input element.
value?_string
Specifies 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.

PropDefaultDescription
children?_ReactNode
The 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_ReactNode
This 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​

, Select an animal
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 PartsDescription
sheetThe Inner component wrapping the header, content, footer
sheetWrapperThe overlay component.
sheetHeaderThe Header component in Sheet.
sheetContentThe children components in Sheet.
sheetFooterThe footer component in Sheet.
closeButtonThe closeButton in Sheet.