Select
Picker shown in a bottom sheet, expanded in place, or floating over the page.
A picker with one trigger and three ways of showing its options: a bottom sheet, a list expanded in place, or a panel floating over the page.
Which is right depends on what surrounds the trigger rather than on what the options are, so it is one presentation prop rather than three components.
Use it when the reader recognises the option by seeing it. When they know the name and would rather type it, use Combobox; when there are only a few options and all of them should be visible, use RadioGroup.
Installation
Select ships with the library — no separate install.
import { Select, Input, Label, useSelectSearch } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add selectUsage
<Select
value={fruit}
onValueChange={setFruit}
placeholder="Select a fruit"
title="Favorite fruit"
>
<Select.Item value="apple" label="Apple" />
<Select.Item value="banana" label="Banana" />
</Select>Composition
<Select>
<Select.Item value="…" label="…" />
<Select.Group label="…">
<Select.Item value="…" label="…" />
</Select.Group>
</Select>Select.Item— One option.valueidentifies it,labelis what is shown, anddisabledkeeps it listed but unselectable.Select.Group— A titled run of options.labelis the heading. Grouping is presentational — the value is still one flat string — and the filter reaches through it.
Examples
Basic
The trigger shows the selected option’s label, or the placeholder when nothing is chosen.
const [fruit, setFruit] = useState<string>();
<Select
value={fruit}
onValueChange={setFruit}
placeholder="Select a fruit"
title="Favorite fruit"
>
<Select.Item value="apple" label="Apple" />
<Select.Item value="banana" label="Banana" />
<Select.Item value="cherry" label="Cherry" />
</Select>From data
<Select value={country} onValueChange={setCountry} title="Country">
{countries.map((c) => (
<Select.Item key={c.code} value={c.code} label={c.name} />
))}
</Select>In a form
The options open in a sheet, so nothing in the form moves.
<View className="gap-4">
<Input label="Full name" />
<View className="gap-1.5">
<Label isRequired>Country</Label>
<Select value={country} onValueChange={setCountry} title="Country">
{/* …items… */}
</Select>
</View>
</View>Expanding in place
The list opens in layout flow and pushes the content below it down. Inside a settings list that reads as the row growing, which is exactly what you want there.
<Select
presentation="inline"
value={region}
onValueChange={setRegion}
placeholder="Select a region"
>
<Select.Item value="us" label="United States" />
<Select.Item value="eu" label="Europe" />
</Select>Floating over the page
Anchored to the trigger, flipped above it when there is no room below, and clamped inside the screen. Nothing else moves. contentWidth matches the trigger by default; pass content to size to the longest option, or a number for a fixed width.
<Select
presentation="overlay"
contentWidth="content"
offset={12}
value={region}
onValueChange={setRegion}
>
<Select.Item value="us" label="United States" />
<Select.Item value="apac" label="Asia Pacific" />
</Select>Filtering a long list
Past a couple of dozen options, scrolling is not a way of finding anything. searchable puts a filter above the list, matching case-insensitively on any part of a label — so “lo” finds both London and Los Angeles. The field is never focused on open: on a phone that would throw the keyboard over the very list you are trying to read.
<Select
searchable
searchPlaceholder="Search cities"
emptyMessage="No city by that name"
value={zone}
onValueChange={setZone}
placeholder="Select a time zone"
title="Time zone"
>
{timezones.map((tz) => (
<Select.Item key={tz.value} value={tz.value} label={tz.label} />
))}
</Select>An option you cannot pick
A plan above the current tier, a region with nothing in stock. Keep it in the list rather than dropping it: an option that disappears reads as one that was never offered.
<Select value={plan} onValueChange={setPlan} title="Plan">
<Select.Item value="starter" label="Starter" />
<Select.Item value="team" label="Team" />
<Select.Item value="enterprise" label="Enterprise — contact sales" disabled />
</Select>Grouped options
Headings make a long list scannable before the filter narrows it.
<Select value={zone} onValueChange={setZone} searchable title="Time zone">
<Select.Group label="Europe">
<Select.Item value="europe/london" label="London" />
<Select.Item value="europe/paris" label="Paris" />
</Select.Group>
<Select.Group label="Americas">
<Select.Item value="america/new_york" label="New York" />
<Select.Item value="america/los_angeles" label="Los Angeles" />
</Select.Group>
</Select>Restyling the field and its filter
className is the wrapper; triggerClassName is the field. The filter takes the same pair one level down — searchContainerClassName for the box, searchInputClassName for the text in it.
<Select
searchable
searchPlaceholder="Search cities"
emptyMessage="No city by that name"
value={zone}
onValueChange={setZone}
placeholder="Select a time zone"
title="Time zone"
className="w-full"
triggerClassName="rounded-full border-primary bg-primary/5 px-5"
valueClassName="text-primary"
placeholderClassName="italic"
searchContainerClassName="rounded-full bg-muted"
searchInputClassName="text-sm"
emptyClassName="py-10 text-base"
>
{timezones.map((tz) => (
<Select.Item key={tz.value} value={tz.value} label={tz.label} />
))}
</Select>A list of your own
Past a few hundred options the plain scroller is the wrong shape. Render them with a virtualized list and filter your own data off useSelectSearch; valueLabel gives the trigger the label it can no longer read off a row.
function TimezoneOptions() {
const { query } = useSelectSearch();
const rows = useMemo(() => filterTimezones(query), [query]);
return (
<FlashList
data={rows}
estimatedItemSize={44}
renderItem={({ item }) => <Select.Item value={item.id} label={item.name} />}
/>
);
}
<Select
searchable
value={zone}
valueLabel={zoneName}
onValueChange={setZone}
placeholder="Select a timezone"
>
<TimezoneOptions />
</Select>Variants
presentation
sheet(default)inlineoverlay
{/* Takes the bottom of the screen. The default, and the right one
for a long list or a small screen. */}
<Select presentation="sheet" title="Region" value={region} onValueChange={setRegion}>
<Select.Item value="us" label="United States" />
</Select>
{/* Expands in layout flow — everything below moves down. */}
<Select presentation="inline" value={region} onValueChange={setRegion}>
<Select.Item value="us" label="United States" />
</Select>
{/* Floats over the page, anchored to the trigger. Nothing else moves. */}
<Select presentation="overlay" value={region} onValueChange={setRegion}>
<Select.Item value="us" label="United States" />
</Select>API Reference
Select.Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | |
label | string | — | |
className | string | — | Extra classes for the option row. |
labelClassName | string | — | Extra classes for the option's label. |
disabled | boolean | — | Shows the option but refuses it — a plan above the current tier, a region with nothing in stock. Kept in the list rather than dropped from it, because an option that vanishes reads as one that never existed. |
Select.Group
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Heading over the run of options. Announced as a header, so a screen reader reaching the group is told what it is before walking into it. |
className | string | — | Extra classes for the group wrapper. |
labelClassName | string | — | Extra classes for the heading. |
Select
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Extra classes for the wrapper around the trigger — the box the select occupies in your layout, which is where margins and widths belong. To restyle the field itself, use triggerClassName. |
value | string | — | The selected option's value. Leave unset for the placeholder. |
valueLabel | string | — | What the trigger shows for the current value. Select reads the label off its Select.Item children, which it cannot do when a list component renders those rows — the elements do not exist until the list decides to draw them, and the selected one may be scrolled far out of view. Pass the label yourself in that case; otherwise leave it unset and the trigger will find it. |
onValueChange | (value: string) => void | — | Called with the value of the option that was picked. |
placeholder | string | 'Select an option' | Shown on the trigger while nothing is selected. |
disabled | boolean | — | Refuses the trigger and dims it. The options cannot be opened. |
triggerClassName | string | — | Extra classes for the trigger — the field you press to open the list. |
valueClassName | string | — | Extra classes for the selected option's text on the trigger. |
placeholderClassName | string | — | Extra classes for the placeholder text on the trigger. |
listClassName | string | — | Extra classes for the surface the options sit on. In sheet the sheet is that surface, so this reaches the block of options inside it instead. |
searchClassName | string | — | Extra classes for the row the filter field sits in. searchable only. |
searchInputClassName | string | — | Extra classes for the filter field itself. searchable only. |
searchContainerClassName | string | — | Extra classes for the box drawn around the filter field — its fill, border and radius. searchable only. |
emptyClassName | string | — | Extra classes for the message shown when the filter matches nothing. |
presentation | SelectPresentation | 'sheet' | Where the options appear. sheet takes the bottom of the screen, inline expands the list in layout flow, overlay floats it above the page anchored to the trigger. |
title | string | — | Sheet title shown above the options. sheet presentation only. |
contentWidth | 'trigger' | 'content' | number | 'trigger' | Width of the floating list. trigger matches the trigger, content sizes to the longest option, or pass a pixel value. overlay only. |
offset | number | 8 | Gap between the trigger and the floating list. overlay only. |
onOpenChange | (open: boolean) => void | — | Called when the options open or close. |
searchable | boolean | false | Put a filter above the options, matching case-insensitively on any part of an option's label. For a list long enough that scrolling it is not finding anything — countries, currencies, a repository's branches. The field is not focused on open: on a phone that would throw the keyboard over the very list you are trying to look at. |
searchPlaceholder | string | 'Search' | Placeholder for the filter field. searchable only. |
emptyMessage | string | 'No matches' | Shown in place of the list when the filter matches nothing. |
native | boolean | — | Render the platform's own picker instead of the trigger-and-list pair. Requires the optional @expo/ui package; without it this prop does nothing. Theme tokens do not apply — the platform draws the picker, so className, title and presentation are ignored. Select.Item children still declare the options. |
nativeAppearance | 'menu' | 'wheel' | 'menu' | Native picker style. menu is a compact button opening a dropdown; wheel is an always-visible rotor (iOS; falls back to menu elsewhere). |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Choosing a presentation
sheet takes the bottom of the screen. Reach for it when the list is long, or on a small screen where an anchored panel would cover the very thing you are choosing for.
inline expands the list in normal layout flow, so everything below it moves down. That reads as the row growing inside a settings list, and reads as the page jumping anywhere else.
overlay floats the list through a portal, anchored to the trigger and flipped above it when there is no room below. Nothing else on the screen moves. contentWidth and offset apply here only.
Styling
className is the wrapper around the trigger — the box the select occupies in your layout, and where widths and margins belong. It is not the field. To restyle the field itself, use triggerClassName.
Everything else the control draws has its own prop, so nothing has to be reached through a parent selector:
| Prop | Where it lands |
|---|---|
className | The wrapper around the trigger |
triggerClassName | The trigger — the field you press |
valueClassName | The selected option's text on the trigger |
placeholderClassName | The placeholder text on the trigger |
listClassName | The surface the options sit on |
searchClassName | The row the filter field sits in |
searchInputClassName | The filter field itself |
searchContainerClassName | The box drawn around the filter field |
emptyClassName | The message shown when the filter matches nothing |
Select.Item takes className and labelClassName, and Select.Group takes className and labelClassName, so an individual option or heading can be styled where the whole list should not be.
In sheet there is no list surface of the select's own — the sheet is the surface — so listClassName lands on the block of options inside it.
None of these reach a native picker. The platform draws that one.
Native rendering
Pass native to render the platform’s own picker instead — SwiftUI on iOS, Jetpack Compose on Android. It needs the optional @expo/ui package and is a silent no-op without it.
Theme tokens do not apply in native mode: the platform draws the control with its own colours and metrics, so className, triggerClassName, presentation and every other styling prop are ignored. A native picker always has a selection, so an unset value shows the first option rather than the placeholder — set an initial value or add an explicit "None" item.
The portable native picker cannot disable one option independently. When any Select.Item is disabled, Select keeps the styled presentation even if native is requested, so the disabled choice remains visible without becoming selectable.
See Native rendering for the full prop-by-prop breakdown.
Filtering a long list
searchable puts a filter above the options in every presentation, matching case-insensitively on any part of an option's label. It narrows what is shown rather than what is declared, so the options stay where they are and nothing has to be lifted into state; searchPlaceholder names the field and emptyMessage replaces the list when nothing matches. The filter clears itself when the list closes, so it is never waiting there the next time with most of the options missing.
searchClassName, searchInputClassName and searchContainerClassName restyle the field: the row it sits in, the text and its placeholder, and the box drawn around it. The last of those is the one to reach for to change its fill, border or radius, because those belong to the box rather than to the text inside it.
The field is deliberately not focused on open — on a phone that throws the keyboard over the list you are trying to look at — and the option scrollers dismiss the keyboard on a drag, so you can get back to reading without first tapping somewhere neutral.
Grouping options
Select.Group wraps a run of options under a heading, which is what a list long enough to want a filter is usually long enough to want. Grouping is presentational: the Select still reports one flat string, and Select.Item needs to know nothing about being inside a group.
The filter reaches through groups. A group is rebuilt around whatever survives inside it and dropped when that is nothing, so a heading never stands over an empty section — which would read as a section that failed to load rather than one the query emptied.
The heading is announced as a header, so a screen reader reaching the group is told what it is before walking into it.
Very long lists, and lists you render yourself
The options sit in a plain scroller, which is the right shape up to a few hundred rows and the wrong one after that. Past that, render them with a virtualized list of your own.
Select filters the options it renders. It cannot filter rows a list component
draws, because those elements do not exist until the list decides to draw them
— so when you own the list, you own the filtering, and useSelectSearch hands
you the query to do it with. Select.Item still works wherever your rows put
it: selection travels by context, not by position.
function TimezoneOptions() {
const { query } = useSelectSearch();
const rows = useMemo(() => filterTimezones(query), [query]);
return (
<FlashList
data={rows}
estimatedItemSize={44}
renderItem={({ item }) => <Select.Item value={item.id} label={item.name} />}
/>
);
}
<Select searchable value={zone} valueLabel={zoneName} onValueChange={setZone}>
<TimezoneOptions />
</Select>Two things go with it. valueLabel is what the trigger shows, because the
selected row may never have been drawn for Select to read a label off. And
emptyMessage is not used — Select was never shown the rows, so it does not
claim to know whether the query matched; render your own empty row when your
data comes back empty.
A caption, a divider or any other child you put among the options survives the filter untouched, for the same reason: a filter has no opinion about something that is not an option.
Public exports
Values: Select, useSelectSearch
Types: SelectProps, SelectItemProps, SelectGroupProps, SelectPresentation