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 select

Usage

<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. value identifies it, label is what is shown, and disabled keeps it listed but unselectable.
  • Select.Group — A titled run of options. label is 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)
  • inline
  • overlay
{/* 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

PropTypeDefaultDescription
valuestring
labelstring
classNamestringExtra classes for the option row.
labelClassNamestringExtra classes for the option's label.
disabledbooleanShows 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

PropTypeDefaultDescription
labelstringHeading 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.
classNamestringExtra classes for the group wrapper.
labelClassNamestringExtra classes for the heading.

Select

PropTypeDefaultDescription
classNamestringExtra 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.
valuestringThe selected option's value. Leave unset for the placeholder.
valueLabelstringWhat 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) => voidCalled with the value of the option that was picked.
placeholderstring'Select an option'Shown on the trigger while nothing is selected.
disabledbooleanRefuses the trigger and dims it. The options cannot be opened.
triggerClassNamestringExtra classes for the trigger — the field you press to open the list.
valueClassNamestringExtra classes for the selected option's text on the trigger.
placeholderClassNamestringExtra classes for the placeholder text on the trigger.
listClassNamestringExtra 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.
searchClassNamestringExtra classes for the row the filter field sits in. searchable only.
searchInputClassNamestringExtra classes for the filter field itself. searchable only.
searchContainerClassNamestringExtra classes for the box drawn around the filter field — its fill, border and radius. searchable only.
emptyClassNamestringExtra classes for the message shown when the filter matches nothing.
presentationSelectPresentation'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.
titlestringSheet 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.
offsetnumber8Gap between the trigger and the floating list. overlay only.
onOpenChange(open: boolean) => voidCalled when the options open or close.
searchablebooleanfalsePut 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.
searchPlaceholderstring'Search'Placeholder for the filter field. searchable only.
emptyMessagestring'No matches'Shown in place of the list when the filter matches nothing.
nativebooleanRender 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:

PropWhere it lands
classNameThe wrapper around the trigger
triggerClassNameThe trigger — the field you press
valueClassNameThe selected option's text on the trigger
placeholderClassNameThe placeholder text on the trigger
listClassNameThe surface the options sit on
searchClassNameThe row the filter field sits in
searchInputClassNameThe filter field itself
searchContainerClassNameThe box drawn around the filter field
emptyClassNameThe 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

On this page