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.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/select.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/select.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/select.jsonSelect 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> )}With Search
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, ornull/truefor valid.isInvalid+errorMessage— use together for realtime validation feedback without waiting for a form submit.errorMessageaccepts aReactNodeor 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'sKey(ornullwhen cleared). This is the current API; prefer it over the deprecatedonSelectionChange.onOpenChange— Called when the dropdown opens or closes, receivingisOpen: 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/optionsemantics managed by React Aria. - The label, description, and error message are automatically wired via
aria-labelledbyandaria-describedby. - If no visible label is provided, pass
aria-labeloraria-labelledbyto theSelect. - 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 whenenableSearchis 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T) => ReactNode) | - | Static children or render function for dynamic items |
label | ReactNode | - | The visible label for the field |
items | Iterable<T> | - | The list of items for a dynamic collection (controlled) |
value | Key | null | - | The key of the selected item (controlled) |
defaultValue | Key | - | The default selected key (uncontrolled) |
onChange | (key: Key | null) => void | - | Called when the selected value changes |
isDisabled | boolean | false | Whether the field is disabled |
isReadOnly | boolean | false | Whether the field is read-only |
isRequired | boolean | false | Whether the field is required |
isInvalid | boolean | false | Whether the field should display as invalid |
validate | (value: Key | null) => ValidationError | true | null | undefined | - | Custom validation function |
errorMessage | ReactNode | ((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 |
classNames | SlotsToClasses<SelectVariantSlots | "error"> | - | Custom Tailwind classes for component slots |
description | ReactNode | null | - | Helper text shown below the trigger |
placeholder | string | - | Placeholder text shown when no value is selected |
enableSearch | boolean | false | Render a search input inside the dropdown to filter options |
searchPlaceholder | string | - | Placeholder text for the search field; defaults to a localised "Search..." |
searchIcon | ReactNode | - | Icon to display in the search field |
renderSelectValue | ReactNode | ((props: SelectValueRenderProps<T>) => ReactNode) | - | Custom renderer for the selected value in the trigger |
listLayoutOptions | ListLayoutOptions | - | Additional props spread to the list layout (virtualisation) |
popoverProps | Partial<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:
| Prop | Replacement |
|---|---|
selectedKey | value |
defaultSelectedKey | defaultValue |
onSelectionChange | onChange |
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:
| Property | Type | Description |
|---|---|---|
isPlaceholder | boolean | Whether the placeholder is currently shown (no selection) |
selectedText | string | The text content of the selected item |
selectedItem | T | null | The 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:
| Property | Type | Description |
|---|---|---|
isInvalid | boolean | Whether the field is currently invalid |
validationErrors | string[] | List of validation error strings |
validationDetails | ValidityState | The native HTML ValidityState object |
SelectItem
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((renderProps: ListBoxItemRenderProps) => ReactNode) | - | The item content, or a render function |
id | Key | - | A unique identifier for the item; used as the value passed to onChange |
textValue | string | - | Plain-text value for filtering and accessibility; required when children is not a string |
isDisabled | boolean | false | Whether the item is disabled |
classNames | SlotsToClasses<"base" | "text"> | - | Custom Tailwind classes for item slots |
Inherits all remaining props from React Aria's ListBoxItem.