Select Menu
SelectMenu is a fully custom select component built on react-aria-components, designed for app-like experiences (e.g. webviews in native apps). Unlike the native Select, it renders an in-page dropdown instead of delegating to the OS picker.
It uses the modern appearance by default with support for white and grey themes, a dark color mode, three sizes (sm, md, lg), and an error state.
Selection is key-based: give each SelectMenu.Item an id, and control the value with selectedKey/defaultSelectedKey and onSelectionChange. Label the menu with aria-label (or aria-labelledby) on the root SelectMenu.
Basic usage
<SelectMenu theme="white" aria-label="Fruit" defaultSelectedKey="banana">
<SelectMenu.Trigger placeholder="Pick a fruit" />
<SelectMenu.Content>
<SelectMenu.Item id="apple">Apple</SelectMenu.Item>
<SelectMenu.Item id="banana">Banana</SelectMenu.Item>
<SelectMenu.Item id="cherry">Cherry</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
With groups
<SelectMenu theme="white" aria-label="Food">
<SelectMenu.Trigger placeholder="Pick a food" />
<SelectMenu.Content>
<SelectMenu.Group>
<SelectMenu.Label>Fruits</SelectMenu.Label>
<SelectMenu.Item id="apple">Apple</SelectMenu.Item>
<SelectMenu.Item id="banana">Banana</SelectMenu.Item>
</SelectMenu.Group>
<SelectMenu.Separator />
<SelectMenu.Group>
<SelectMenu.Label>Vegetables</SelectMenu.Label>
<SelectMenu.Item id="carrot">Carrot</SelectMenu.Item>
<SelectMenu.Item id="broccoli">Broccoli</SelectMenu.Item>
</SelectMenu.Group>
</SelectMenu.Content>
</SelectMenu>
Sizes
<div className="flex flex-col gap-4 min-w-70">
<SelectMenu theme="white" size="sm" aria-label="Small" defaultSelectedKey="apple">
<SelectMenu.Trigger placeholder="Small" />
<SelectMenu.Content>
<SelectMenu.Item id="apple">Apple</SelectMenu.Item>
<SelectMenu.Item id="banana">Banana</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
<SelectMenu theme="white" size="md" aria-label="Medium" defaultSelectedKey="apple">
<SelectMenu.Trigger placeholder="Medium" />
<SelectMenu.Content>
<SelectMenu.Item id="apple">Apple</SelectMenu.Item>
<SelectMenu.Item id="banana">Banana</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
<SelectMenu theme="white" size="lg" aria-label="Large" defaultSelectedKey="apple">
<SelectMenu.Trigger placeholder="Large" />
<SelectMenu.Content>
<SelectMenu.Item id="apple">Apple</SelectMenu.Item>
<SelectMenu.Item id="banana">Banana</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
</div>
Disabled items
<SelectMenu theme="white" aria-label="Options">
<SelectMenu.Trigger placeholder="Pick an option" />
<SelectMenu.Content>
<SelectMenu.Item id="enabled">Enabled option</SelectMenu.Item>
<SelectMenu.Item id="disabled" isDisabled>Disabled option</SelectMenu.Item>
<SelectMenu.Item id="another">Another option</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
Matching the trigger width
By default the dropdown floors at the trigger width but grows to fit its content (up to a viewport/400px cap). Pass matchTriggerWidth on SelectMenu.Content to pin it to the trigger width instead — longer option labels wrap to fit rather than widening the menu. Compare the two below (both triggers are w-56):
<div className="flex gap-4 items-start">
<SelectMenu theme="white" aria-label="Grows to fit" defaultSelectedKey="a" className="w-56">
<SelectMenu.Trigger placeholder="Default" />
<SelectMenu.Content>
<SelectMenu.Item id="a">Apple</SelectMenu.Item>
<SelectMenu.Item id="b">Pineapple, mango and passionfruit smoothie</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
<SelectMenu theme="white" aria-label="Matches trigger" defaultSelectedKey="a" className="w-56">
<SelectMenu.Trigger placeholder="matchTriggerWidth" />
<SelectMenu.Content matchTriggerWidth>
<SelectMenu.Item id="a">Apple</SelectMenu.Item>
<SelectMenu.Item id="b">Pineapple, mango and passionfruit smoothie</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
</div>
Dark color mode
colorMode="light | dark"
colorMode="dark" recolours the trigger, chevron, dropdown, items, labels and separators for use on dark (grey-1000) surfaces. It works with either theme; the error state keeps its light look.
<SelectMenu colorMode="dark" aria-label="Choose a video" defaultSelectedKey="1" className="w-64">
<SelectMenu.Trigger />
<SelectMenu.Content>
<SelectMenu.Item id="1">Nuclear fission</SelectMenu.Item>
<SelectMenu.Item id="2">Nuclear fusion</SelectMenu.Item>
</SelectMenu.Content>
</SelectMenu>
Full-height mobile layouts
While open, the dropdown locks page scroll by setting overflow: hidden on <html>. On iOS Safari this changes the dynamic viewport, so a page sized with 100dvh reflows behind the menu. Pass isNonModal on SelectMenu.Content to skip the scroll lock on those layouts.
API Reference
| Prop | Type | Default | Required |
|---|---|---|---|
allowsEmptyCollection | boolean | - | - |
autoComplete | string | - | - |
autoFocus | boolean | - | - |
className | string | - | - |
colorMode | "light" | "dark" | light | - |
defaultOpen | boolean | - | - |
defaultSelectedKey | null | string | number | - | - |
defaultValue | null | string | number | - | - |
disabledKeys | Iterable<Key> | - | - |
form | string | - | - |
isDisabled | boolean | - | - |
isInvalid | boolean | - | - |
isOpen | boolean | - | - |
isRequired | boolean | - | - |
name | string | - | - |
onBlur | (e: FocusEvent<Element, Element>) => void | - | - |
onChange | (value: Key | null) => void | - | - |
onFocus | (e: FocusEvent<Element, Element>) => void | - | - |
onFocusChange | (isFocused: boolean) => void | - | - |
onKeyDown | (e: KeyboardEvent) => void | - | - |
onKeyUp | (e: KeyboardEvent) => void | - | - |
onOpenChange | (isOpen: boolean) => void | - | - |
onSelectionChange | (key: Key | null) => void | - | - |
placeholder | string | 'Select an item' (localized) | - |
render | DOMRenderFunction<"div", SelectRenderProps> | - | - |
selectedKey | null | string | number | - | - |
selectionMode | "single" | 'single' | - |
shouldCloseOnSelect | boolean | - | - |
size | "sm" | "md" | "lg" | md | - |
slot | null | string | - | - |
state | "error" | - | - |
theme | "white" | "grey" | grey | - |
validate | (value: Key) => true | ValidationError | null | undefined | - | - |
validationBehavior | "native" | "aria" | 'native' | - |
value | null | string | number | - | - |
| Prop | Type | Default | Required |
|---|---|---|---|
aria-controls | string | - | - |
aria-current | boolean | - | - |
aria-disabled | boolean | - | - |
aria-expanded | boolean | - | - |
aria-haspopup | boolean | - | - |
aria-pressed | boolean | - | - |
autoFocus | boolean | - | - |
className | string | (values: ButtonRenderProps & { defaultClassName: string | undefined; }) => string | 'react-aria-Button' | - |
form | string | - | - |
formAction | string | (formData: FormData) => void | Promise<void> | - | - |
formEncType | string | - | - |
formMethod | string | - | - |
formNoValidate | boolean | - | - |
formTarget | string | - | - |
isDisabled | boolean | - | - |
isPending | boolean | - | - |
name | string | - | - |
onBlur | (e: FocusEvent<Element, Element>) => void | - | - |
onClick | (e: MouseEvent<FocusableElement, MouseEvent>) => void | - | - |
onFocus | (e: FocusEvent<Element, Element>) => void | - | - |
onFocusChange | (isFocused: boolean) => void | - | - |
onHoverChange | (isHovering: boolean) => void | - | - |
onHoverEnd | (e: HoverEvent) => void | - | - |
onHoverStart | (e: HoverEvent) => void | - | - |
onKeyDown | (e: KeyboardEvent) => void | - | - |
onKeyUp | (e: KeyboardEvent) => void | - | - |
onPress | (e: PressEvent) => void | - | - |
onPressChange | (isPressed: boolean) => void | - | - |
onPressEnd | (e: PressEvent) => void | - | - |
onPressStart | (e: PressEvent) => void | - | - |
onPressUp | (e: PressEvent) => void | - | - |
placeholder | string | - | - |
preventFocusOnPress | boolean | - | - |
render | DOMRenderFunction<"button", ButtonRenderProps> | - | - |
slot | null | string | - | - |
style | CSSProperties | (values: ButtonRenderProps & { defaultStyle: CSSProperties; }) => CSSProperties | undefined | - | - |
type | "button" | "submit" | "reset" | 'button' | - |
value | string | - | - |
| Prop | Type | Default | Required |
|---|---|---|---|
arrowBoundaryOffset | number | 0 | - |
arrowRef | RefObject<Element | null> | - | - |
boundaryElement | Element | document.body | - |
className | string | (values: PopoverRenderProps & { defaultClassName: string | undefined; }) => string | 'react-aria-Popover' | - |
containerPadding | number | 12 | - |
crossOffset | number | 0 | - |
defaultOpen | boolean | - | - |
getTargetRect | (target: Element) => DOMRect | null | undefined | target.getBoundingClientRect() | - |
isEntering | boolean | - | - |
isExiting | boolean | - | - |
isKeyboardDismissDisabled | boolean | false | - |
isNonModal | boolean | - | - |
isOpen | boolean | - | - |
matchTriggerWidth | boolean | | - |
maxHeight | number | - | - |
offset | number | 4 | - |
onOpenChange | (isOpen: boolean) => void | - | - |
placement | "left" | "right" | "top" | "bottom" | "start" | "end" | "bottom left" | "bottom right" | "bottom start" | "bottom end" | "top left" | "top right" | "top start" | "top end" | "left top" | "left bottom" | "start top" | "start bottom" | "right top" | "right bottom" | "end top" | "end bottom" | 'bottom' | - |
render | DOMRenderFunction<"div", PopoverRenderProps> | - | - |
scrollRef | RefObject<Element | null> | overlayRef | - |
shouldCloseOnInteractOutside | (element: Element) => boolean | - | - |
shouldFlip | boolean | true | - |
shouldUpdatePosition | boolean | true | - |
slot | null | string | - | - |
style | CSSProperties | (values: PopoverRenderProps & { defaultStyle: CSSProperties; }) => CSSProperties | undefined | - | - |
trigger | string | - | - |
triggerRef | RefObject<Element | null> | - | - |
UNSTABLE_portalContainer | Element | document.body | - |
| Prop | Type | Default | Required |
|---|---|---|---|
aria-label | string | - | - |
className | string | (values: ListBoxItemRenderProps & { defaultClassName: string | undefined; }) => string | 'react-aria-ListBoxItem' | - |
id | string | number | - | - |
isDisabled | boolean | - | - |
onAction | () => void | - | - |
onBlur | (e: FocusEvent<HTMLDivElement, Element>) => void | - | - |
onClick | (e: MouseEvent<FocusableElement, MouseEvent>) => void | - | - |
onFocus | (e: FocusEvent<HTMLDivElement, Element>) => void | - | - |
onFocusChange | (isFocused: boolean) => void | - | - |
onHoverChange | (isHovering: boolean) => void | - | - |
onHoverEnd | (e: HoverEvent) => void | - | - |
onHoverStart | (e: HoverEvent) => void | - | - |
onKeyDown | (e: KeyboardEvent) => void | - | - |
onKeyUp | (e: KeyboardEvent) => void | - | - |
onPress | (e: PressEvent) => void | - | - |
onPressChange | (isPressed: boolean) => void | - | - |
onPressEnd | (e: PressEvent) => void | - | - |
onPressStart | (e: PressEvent) => void | - | - |
onPressUp | (e: PressEvent) => void | - | - |
render | (props: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> | DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement>, renderProps: ListBoxItemRenderProps) => ReactElement<...> | - | - |
style | CSSProperties | (values: ListBoxItemRenderProps & { defaultStyle: CSSProperties; }) => CSSProperties | undefined | - | - |
textValue | string | - | - |
value | object | - | - |
| Prop | Type | Default | Required |
|---|---|---|---|
render | DOMRenderFunction<"header", undefined> | - | - |
| Prop | Type | Default | Required |
|---|---|---|---|
className | string | 'react-aria-Separator' | - |
elementType | string | - | - |
orientation | "horizontal" | "vertical" | 'horizontal' | - |
render | DOMRenderFunction<"div" | "hr", undefined> | - | - |
slot | null | string | - | - |
style | CSSProperties | - | - |