Open UI

Select

A collapsible dropdown for choosing one option. For type-ahead filtering, use ComboBox; for multi-select, use TagField.

import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  return (    <div className="w-full max-w-xs">      <Select label="Favourite animal">        <SelectItem>Aardvark</SelectItem>        <SelectItem>Cat</SelectItem>        <SelectItem>Dog</SelectItem>        <SelectItem>Kangaroo</SelectItem>        <SelectItem>Panda</SelectItem>        <SelectItem>Snake</SelectItem>      </Select>    </div>  )}

Usage

Use Select for short lists where users pick one option from a dropdown. Use ComboBox when users need type-ahead filtering. Use Menu for action lists (not value selection). Use TagField for multi-select with filtering.

import { Select, SelectItem } from "@opengovsg/oui"
<Select label="Favourite animal">
  <SelectItem>Aardvark</SelectItem>
  <SelectItem>Cat</SelectItem>
  <SelectItem>Dog</SelectItem>
</Select>

Alternatively, install the component as local source via the shadcn CLI:

npx shadcn@latest add https://oui.open.gov.sg/r/select.json
pnpm dlx shadcn@latest add https://oui.open.gov.sg/r/select.json
npx shadcn@latest add https://oui.open.gov.sg/r/select.json
bunx --bun shadcn@latest add https://oui.open.gov.sg/r/select.json

Select renders a trigger button that opens a listbox popover for choosing one option, built on React Aria's Select. It handles keyboard navigation, focus management, ARIA semantics, and form validation automatically. OUI extends the base with built-in search/filter support and a custom selected-value renderer.

If the select does not have a visible label, pass aria-label or aria-labelledby instead to identify it to assistive technology.

Anatomy

Select consists of:

  • Select — the trigger button + popover container; manages state
    • SelectItem — one option in the dropdown list

Examples

With Description and Error Message

Use the description prop for helper text below the trigger. Use isInvalid + errorMessage to show validation errors.

import type { Key } from "react-aria-components"import { useState } from "react"import { Select, SelectItem } from "@opengovsg/oui"export const animals = [  { id: "cat", textValue: "Cat" },  { id: "dog", textValue: "Dog" },  { id: "elephant", textValue: "Elephant" },  { id: "lion", textValue: "Lion" },  { id: "tiger", textValue: "Tiger" },  { id: "giraffe", textValue: "Giraffe" },  { id: "dolphin", textValue: "Dolphin" },  { id: "penguin", textValue: "Penguin" },  { id: "zebra", textValue: "Zebra" },  { id: "shark", textValue: "Shark" },  { id: "whale", textValue: "Whale" },  { id: "otter", textValue: "Otter" },  { id: "crocodile", textValue: "Crocodile" },]export const Example = () => {  const [value, setValue] = useState<Key | null>(null)  const isValid = value === "cat"  return (    <div className="w-full max-w-xs">      <Select        description="The second most popular pet in the world"        errorMessage={isValid ? "" : "You must select a cat"}        isInvalid={!isValid}        label="Favorite Animal"        placeholder="Select an animal"        value={value}        onChange={setValue}      >        {animals.map((animal) => (          <SelectItem key={animal.id} id={animal.id}>            {animal.textValue}          </SelectItem>        ))}      </Select>    </div>  )}

Controlled

Use value and onChange to control the selected key programmatically. Use defaultValue for an uncontrolled initial selection.

import type { Key } from "react-aria-components"import { useState } from "react"import { Select, SelectItem } from "@opengovsg/oui"const animals = [  { id: "cat", label: "Cat" },  { id: "dog", label: "Dog" },  { id: "rabbit", label: "Rabbit" },  { id: "hamster", label: "Hamster" },]export const Example = () => {  const [value, setValue] = useState<Key | null>(null)  return (    <div className="flex w-full max-w-xs flex-col gap-2">      <Select label="Favourite animal" value={value} onChange={setValue}>        {animals.map((animal) => (          <SelectItem key={animal.id} id={animal.id}>            {animal.label}          </SelectItem>        ))}      </Select>      <p className="text-sm">Selected: {value ?? "None"}</p>    </div>  )}

Custom Selected Value

The renderSelectValue prop lets you customise how the selected option is displayed in the trigger button. It receives SelectValueRenderProps, which includes isPlaceholder and selectedText.

import { Select, SelectItem } from "@opengovsg/oui"const languages = [  { id: "en", textValue: "English", code: "EN" },  { id: "zh", textValue: "Chinese", code: "ZH" },  { id: "ms", textValue: "Malay", code: "MS" },  { id: "ta", textValue: "Tamil", code: "TA" },]export const Example = () => {  return (    <div className="w-full max-w-xs">      <Select        label="Language"        items={languages}        defaultSelectedKey="en"        renderSelectValue={(renderProps) => {          if (renderProps.isPlaceholder) {            return <span>Select a language</span>          }          const selected = languages.find(            (l) => l.textValue === renderProps.selectedText,          )          return (            <span>              {selected?.code} — {renderProps.selectedText}            </span>          )        }}      >        {(item) => <SelectItem id={item.id}>{item.textValue}</SelectItem>}      </Select>    </div>  )}

Set enableSearch to render a search input inside the dropdown. The component filters options using a case-insensitive contains match.

import type { Key } from "react-aria-components"import { useState } from "react"import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  const countries = [    { id: 1, name: "Singapore" },    { id: 2, name: "Malaysia" },    { id: 3, name: "Indonesia" },    { id: 4, name: "Thailand" },    { id: 5, name: "Philippines" },    { id: 6, name: "Vietnam" },    { id: 7, name: "Myanmar" },    { id: 8, name: "Cambodia" },    { id: 9, name: "Laos" },    { id: 10, name: "Brunei" },    { id: 11, name: "East Timor" },  ]  const [selectedId, setSelectedId] = useState<Key | null>(null)  return (    <div className="flex w-full max-w-xs flex-col gap-2">      <Select        label="Select a country"        items={countries}        value={selectedId}        onChange={setSelectedId}        enableSearch      >        {(item) => <SelectItem>{item.name}</SelectItem>}      </Select>      <p className="text-content-medium text-sm">        Selected: {countries.find((c) => c.id === selectedId)?.name ?? "None"}      </p>    </div>  )}

Search Icon

Pass a searchIcon node to display an icon inside the search field.

import type { Key } from "react-aria-components"import { useState } from "react"import { SearchIcon } from "lucide-react"import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  const countries = [    { id: 1, name: "Singapore" },    { id: 2, name: "Malaysia" },    { id: 3, name: "Indonesia" },    { id: 4, name: "Thailand" },    { id: 5, name: "Philippines" },    { id: 6, name: "Vietnam" },    { id: 7, name: "Myanmar" },    { id: 8, name: "Cambodia" },    { id: 9, name: "Laos" },    { id: 10, name: "Brunei" },    { id: 11, name: "East Timor" },  ]  const [selectedId, setSelectedId] = useState<Key | null>(null)  return (    <div className="flex w-full max-w-xs flex-col gap-4">      <Select        label="Select a country"        items={countries}        value={selectedId}        onChange={setSelectedId}        enableSearch        searchIcon={<SearchIcon />}      >        {(item) => <SelectItem>{item.name}</SelectItem>}      </Select>      <p className="text-content-medium text-sm">        Selected: {countries.find((c) => c.id === selectedId)?.name ?? "None"}      </p>    </div>  )}

Custom Search Placeholder

Override the default localised placeholder ("Search...") with searchPlaceholder.

import type { Key } from "react-aria-components"import { useState } from "react"import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  const fruits = [    { id: 1, name: "Apple" },    { id: 2, name: "Banana" },    { id: 3, name: "Cherry" },    { id: 4, name: "Durian" },    { id: 5, name: "Elderberry" },    { id: 6, name: "Fig" },    { id: 7, name: "Grape" },    { id: 8, name: "Honeydew" },    { id: 9, name: "Kiwi" },    { id: 10, name: "Lemon" },  ]  const [selectedId, setSelectedId] = useState<Key | null>(null)  return (    <div className="flex w-full max-w-xs">      <Select        label="Select a fruit"        items={fruits}        value={selectedId}        onChange={setSelectedId}        enableSearch        searchPlaceholder="Type to filter fruits..."      >        {(item) => <SelectItem>{item.name}</SelectItem>}      </Select>    </div>  )}

Content

Select follows the Collection Components API, accepting both static and dynamic collections.

Static collections pass children directly when the full list is known up front:

<Select label="Animal">
  <SelectItem>Aardvark</SelectItem>
  <SelectItem>Cat</SelectItem>
</Select>

Dynamic collections pass an iterable to items and a render function as children. Each item must have an id property (used automatically if present on the item object).

import type { Key } from "react-aria-components"import { useState } from "react"import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  const options = [    { id: 1, textValue: "Aerospace" },    { id: 2, textValue: "Mechanical" },    { id: 3, textValue: "Civil" },    { id: 4, textValue: "Biomedical" },    { id: 5, textValue: "Nuclear" },    { id: 6, textValue: "Industrial" },    { id: 7, textValue: "Chemical" },    { id: 8, textValue: "Agricultural" },    { id: 9, textValue: "Electrical" },  ]  const [selectedId, setSelectedId] = useState<Key | null>(null)  return (    <div className="flex w-full max-w-xs flex-col">      <Select        label="Engineering"        items={options}        selectedKey={selectedId}        onSelectionChange={setSelectedId}      >        {(item) => <SelectItem>{item.textValue}</SelectItem>}      </Select>      <p>Selected id: {selectedId ?? "None"}</p>    </div>  )}

Use textValue on SelectItem (or on the item object) when the item's accessible text cannot be derived from its children — for example, when children is a render function or contains non-string nodes.

Validation

See the Forms guide for end-to-end patterns including React Hook Form + Zod.

  • isRequired — marks the field as required; validation fires on form submit (native) or immediately (aria).
  • validate — a custom function (value: Key | null) => ValidationError | true | null | undefined. Return a string to show as the error, or null/true for valid.
  • isInvalid + errorMessage — use together for realtime validation feedback without waiting for a form submit. errorMessage accepts a ReactNode or a function (validation: ValidationResult) => ReactNode.
  • validationBehavior"native" (default) blocks form submission when invalid; "aria" only marks the field via ARIA attributes and allows submission — required for React Hook Form.

Events

  • onChange — Called when the selected value changes, receiving the item's Key (or null when cleared). This is the current API; prefer it over the deprecated onSelectionChange.
  • onOpenChange — Called when the dropdown opens or closes, receiving isOpen: boolean.
  • onFocus / onBlur — Called when the trigger receives or loses focus.
  • onFocusChange — Called when the focus state changes, receiving a boolean.
  • onKeyDown / onKeyUp — Called on keyboard events within the trigger.

Accessibility

  • Select is built on a native button with full ARIA combobox/listbox/option semantics managed by React Aria.
  • The label, description, and error message are automatically wired via aria-labelledby and aria-describedby.
  • If no visible label is provided, pass aria-label or aria-labelledby to the Select.
  • Keyboard interaction: Up/Down arrows to navigate the list; Enter or Space to open and select; Escape to close; type-ahead (single character) jumps to the first matching option.
  • Focus returns to the trigger after an item is selected or the popover is dismissed.

Slots

Slots are named regions of the component you can target with custom Tailwind classes via the classNames prop. Each slot below corresponds to a key on the classNames object.

Select slots:

  • base: The root flex-column container wrapping the entire field (label, trigger, description, error).
  • label: The label element rendered above the trigger.
  • trigger: The button that opens the dropdown.
  • icon: The chevron icon inside the trigger button.
  • selectedText: The text span showing the currently selected option (or placeholder).
  • popover: The floating popover that contains the listbox.
  • list: The scrollable listbox element inside the popover.
  • description: The helper text rendered below the trigger.
  • error: The error message rendered below the trigger when the field is invalid.
  • searchField: The container wrapping the search input (visible when enableSearch is true).
  • searchIcon: The icon rendered inside the search field.
  • searchInput: The <input> element inside the search field.

SelectItem slots (via SelectItem's classNames):

  • base: The list item row.
  • text: The primary text label inside the item.

Custom Styles

import { Select, SelectItem } from "@opengovsg/oui"export const Example = () => {  return (    <div className="w-full max-w-xs">      <Select        label="Favourite animal"        placeholder="Pick one"        classNames={{          trigger: "border-2 border-blue-500 rounded-lg",          list: "bg-blue-50",        }}      >        <SelectItem>Aardvark</SelectItem>        <SelectItem>Cat</SelectItem>        <SelectItem>Dog</SelectItem>        <SelectItem>Kangaroo</SelectItem>      </Select>    </div>  )}

Props

Select

PropTypeDefaultDescription
childrenReactNode | ((item: T) => ReactNode)-Static children or render function for dynamic items
labelReactNode-The visible label for the field
itemsIterable<T>-The list of items for a dynamic collection (controlled)
valueKey | null-The key of the selected item (controlled)
defaultValueKey-The default selected key (uncontrolled)
onChange(key: Key | null) => void-Called when the selected value changes
isDisabledbooleanfalseWhether the field is disabled
isReadOnlybooleanfalseWhether the field is read-only
isRequiredbooleanfalseWhether the field is required
isInvalidbooleanfalseWhether the field should display as invalid
validate(value: Key | null) => ValidationError | true | null | undefined-Custom validation function
errorMessageReactNode | ((validation: ValidationResult) => ReactNode)-Error message to display when invalid
validationBehavior"native" | "aria""native"Whether to use native HTML form validation or ARIA-only
size"xs" | "sm" | "md""md"The size of the component
classNamesSlotsToClasses<SelectVariantSlots | "error">-Custom Tailwind classes for component slots
descriptionReactNode | null-Helper text shown below the trigger
placeholderstring-Placeholder text shown when no value is selected
enableSearchbooleanfalseRender a search input inside the dropdown to filter options
searchPlaceholderstring-Placeholder text for the search field; defaults to a localised "Search..."
searchIconReactNode-Icon to display in the search field
renderSelectValueReactNode | ((props: SelectValueRenderProps<T>) => ReactNode)-Custom renderer for the selected value in the trigger
listLayoutOptionsListLayoutOptions-Additional props spread to the list layout (virtualisation)
popoverPropsPartial<PopoverProps>-Additional props spread to the Popover wrapper
onOpenChange(isOpen: boolean) => void-Called when the dropdown opens or closes
onFocus(e: FocusEvent) => void-Called when the trigger receives focus
onBlur(e: FocusEvent) => void-Called when the trigger loses focus
onFocusChange(isFocused: boolean) => void-Called when the focus state changes
onKeyDown(e: KeyboardEvent) => void-Called on key down within the trigger
onKeyUp(e: KeyboardEvent) => void-Called on key up within the trigger

Deprecated props — still accepted for backwards compatibility but should not be used in new code:

PropReplacement
selectedKeyvalue
defaultSelectedKeydefaultValue
onSelectionChangeonChange

Inherits all remaining props from React Aria's Select. Notable ones: autoFocus, name, form, autoComplete, allowsEmptyCollection, excludeFromTabOrder.

renderSelectValue render-prop argument

When renderSelectValue is a function, it receives a SelectValueRenderProps object:

PropertyTypeDescription
isPlaceholderbooleanWhether the placeholder is currently shown (no selection)
selectedTextstringThe text content of the selected item
selectedItemT | nullThe selected item object — deprecated, prefer selectedItems
selectedItems(T | null)[]Array of selected item objects

errorMessage render-prop argument

When errorMessage is a function, it receives a ValidationResult object:

PropertyTypeDescription
isInvalidbooleanWhether the field is currently invalid
validationErrorsstring[]List of validation error strings
validationDetailsValidityStateThe native HTML ValidityState object

SelectItem

PropTypeDefaultDescription
childrenReactNode | ((renderProps: ListBoxItemRenderProps) => ReactNode)-The item content, or a render function
idKey-A unique identifier for the item; used as the value passed to onChange
textValuestring-Plain-text value for filtering and accessibility; required when children is not a string
isDisabledbooleanfalseWhether the item is disabled
classNamesSlotsToClasses<"base" | "text">-Custom Tailwind classes for item slots

Inherits all remaining props from React Aria's ListBoxItem.

On this page