A native wheel picker for React Native that picks any value: numbers, strings, enums or objects.
| iOS | Android |
|---|---|
![]() |
![]() |
βΆ Try the live demo in the browser
Recorded from the Expo demo: flicking the wheels, setting the value from outside, and the string, object and styled tabs.
- π‘ Native on both platforms. iOS uses
UIPickerView. Android draws its own wheel to look like iOS: rows on a 3D cylinder, faded edges, a rounded selection band, fling with snapping and a haptic tick per row. - 𧬠Any value type.
WheelPicker<T>is generic: give it numbers, string unions, enums or objects andonValueChangehands back the sameT, fully typed. - ποΈ Controlled or uncontrolled. Set the value from outside and the wheel scrolls to it. Keep the old value and the wheel snaps back.
- βΎοΈ Infinite scroll with
loop, on both platforms. - π¨ One styling API.
itemStyle,selectionColorandcolorSchemebehave the same on iOS and Android, light and dark. - π Runs everywhere. Where the native view is missing, in Expo Go, Snack or on the web, the same component renders a JS wheel with the same props.
- π¦ No dependencies. Nothing to install besides the package itself, and it autolinks.
- Installation
- Quick start
- Recipes
- API
- How it compares
- Troubleshooting
- Examples
- Development
- Contributing
- License
npm install react-native-next-wheel-picker
cd ios && pod installRequires React Native 0.73 or newer; Android builds with JDK 17.
In Expo Go, Snack and on the web the picker renders its JS wheel, so it works right after
npx expo install. For the native wheel, use a
development build; it needs no
config plugin, autolinking picks it up during prebuild.
-
Install it:
npx expo install react-native-next-wheel-picker
-
For the native wheel, build the app with its native code, locally or on EAS:
npx expo run:ios # or: npx expo run:android eas build --profile development # or any profile of yours
-
Use it like any other component, in Expo Router screens too:
import { WheelPicker } from 'react-native-next-wheel-picker';
Rebuild after installing or upgrading the package; JS changes alone reload as usual. Add
expo-dev-client to keep the
npx expo start + QR workflow with your own build.
import { useState } from 'react';
import { WheelPicker } from 'react-native-next-wheel-picker';
const SIZES = [
{ label: 'Small', value: 's' },
{ label: 'Medium', value: 'm' },
{ label: 'Large', value: 'l' },
] as const;
export function SizePicker() {
const [size, setSize] = useState<'s' | 'm' | 'l'>('m');
return <WheelPicker items={SIZES} value={size} onValueChange={setSize} />;
}items is a list of { label, value }. The wheel shows the labels and reports the values.
A controlled wheel follows value. Change it from anywhere and the wheel scrolls to the matching
row; this is what the First, Last and Random buttons in the recordings do.
const FRUITS = [
{ label: 'π Apple', value: 'apple' },
{ label: 'π Cherry', value: 'cherry' },
{ label: 'π Lemon', value: 'lemon' },
] as const;
type Fruit = (typeof FRUITS)[number]['value'];
export function FruitPicker() {
const [fruit, setFruit] = useState<Fruit>('cherry');
return (
<>
<WheelPicker items={FRUITS} value={fruit} onValueChange={setFruit} />
<Button title="First" onPress={() => setFruit('apple')} />
<Button title="Last" onPress={() => setFruit('lemon')} />
</>
);
}onValueChange fires only when the user scrolls, never for a value set from outside. A value that
matches no item selects the first row.
The wheel always shows value. A parent that ignores a change, by keeping its old value, snaps the
wheel back:
<WheelPicker
items={SEATS}
value={seat}
onValueChange={(next) => {
if (!next.taken) setSeat(next);
}}
/>Leave value out and the wheel keeps its own position, starting on the first row:
<WheelPicker items={SIZES} onValueChange={(size) => console.log(size)} />Without keyExtractor, value is compared to the items with Object.is, so an object must be the
very same reference as one of the items. That breaks as soon as the selection comes from somewhere
else: a refetch, persisted state or navigation params. keyExtractor returns the identity to compare
instead:
type User = { id: string; name: string };
export function AssigneePicker({ users, assignee, onAssign }: Props) {
// `assignee` came from another request, so it is a different object than the item in `users`.
return (
<WheelPicker
items={users.map((user) => ({ label: user.name, value: user }))}
value={assignee}
keyExtractor={(user) => user.id}
onValueChange={onAssign}
/>
);
}onValueChange still hands back the item's object, not the key.
loop puts the first row after the last, so the wheel scrolls without end:
const pad = (n: number) => String(n).padStart(2, '0');
const HOURS = Array.from({ length: 24 }, (_, i) => ({ label: pad(i), value: i }));
const MINUTES = Array.from({ length: 60 }, (_, i) => ({ label: pad(i), value: i }));
export function TimePicker() {
const [hour, setHour] = useState(9);
const [minute, setMinute] = useState(41);
return (
<View style={{ flexDirection: 'row' }}>
<WheelPicker style={{ flex: 1 }} items={HOURS} value={hour} onValueChange={setHour} loop />
<WheelPicker
style={{ flex: 1 }}
items={MINUTES}
value={minute}
onValueChange={setMinute}
loop
/>
</View>
);
}<WheelPicker
items={SIZES}
value={size}
onValueChange={setSize}
colorScheme="dark"
selectionColor="#0a84ff66"
itemStyle={{ fontSize: 28, fontWeight: '700', color: '#ffffff', height: 180 }}
/>itemStyletakesfontSize(default 21),color,fontFamily,fontWeightandfontStyle. Fonts resolve the way<Text>resolves them.itemStyle.heightis the wheel's height, default 216. Each row is the font's line height plus 20.- Without colors the wheel follows the system appearance;
colorSchemeforces light or dark. - Android accepts
#RGB,#RRGGBBAA,rgb()andPlatformColorfor every color, and readsfontSizein sp like the rest of React Native.
| Prop | Type | Default | Description |
|---|---|---|---|
items |
{ label: string; value: T }[] |
required | Rows of the wheel. |
onValueChange |
(value: T, index: number) => void |
required | Called with the value of the row the user's scroll lands on. |
value |
T |
β | Selected value. Leave it out for an uncontrolled wheel. |
keyExtractor |
(value: T) => string | number |
β | Identity used to match value to an item, e.g. an id. |
loop |
boolean |
false |
Scroll endlessly: the first row follows the last. |
itemStyle |
StyleProp<TextStyle> |
β | Row font and color, and the wheel's height. |
selectionColor |
ColorValue |
system fill | Fill of the band behind the selected row. |
colorScheme |
'light' | 'dark' |
system | Forces light or dark default colors. |
style |
StyleProp<ViewStyle> |
β | Style of the view around the wheel. |
accessibilityLabel |
string |
β | What the wheel picks, read before the value, e.g. "Hour". |
testID |
string |
β | Test identifier. |
Every prop works the same on iOS and Android, and in the JS wheel. Screen readers get one adjustable
control per wheel (swipe up or down to change it). On the web the wheel snaps with CSS scroll
snapping, a click on a row turns to it, and a focused wheel takes the arrow keys, Page Up/Down,
Home and End. The package also exports the WheelPickerItem<T> and
WheelPickerProps<T> types.
| next-wheel-picker | @react-native-picker/picker |
JS wheels (FlatList or Reanimated based) | |
|---|---|---|---|
| iOS | native UIPickerView | native UIPickerView | JS imitation |
| Android | native iOS-style wheel | dropdown or dialog | JS imitation |
| Same look on both | β | β | β |
| Typed values of any kind | β | strings and numbers | varies |
| Infinite scroll | β | β | varies |
| Runs in Expo Go and web | β (JS fallback) | β | β |
The fallback keeps Expo Go and the web working; a development build gets the platform's own scrolling and haptics.
The wheel looks or feels different from the native one
The app runs without the picker's native code, so it renders the JS wheel: it is Expo Go, Snack, the
web, or a native app not rebuilt after installing. Rebuild with npx expo run:ios,
npx expo run:android, or cd ios && pod install and a fresh build.
onValueChange does not fire when I set value
By design: it reports the user's scrolls only. A value you set is already known to you, and a callback for it would loop with a parent that stores it.
The wheel shows the first row instead of my object
The value object is not the same reference as an item's. Pass keyExtractor, see
Objects.
The wheel is too tall or too short
The wheel's height is itemStyle.height (default 216), not style.height, the same as React
Native's iOS picker. style sizes the view around it.
| App | What it is | Run it |
|---|---|---|
example-expo/ |
Expo demo with a tab per use case: time, strings, objects and styling. | cd example-expo && npm install && npm run ios |
example/ |
Bare React Native playground with a toggle for every prop, the value and the items. | cd example && npm install && npm run pods && npm run ios |
Both run the library from src/, so edits show up on reload. Swap ios for android to run on
Android.
npm install
npm test
npm run typecheck
npm run lint
npm run build # writes lib/The GIFs in docs/ are recorded from the Expo demo. With Metro running, the demo installed, and
ffmpeg and axe on the path, these scripts replay the same
gestures and rewrite the GIFs:
scripts/gifs/record-ios.sh # booted iPhone 17 Pro simulator
scripts/gifs/record-android.sh # running 1080x2280 emulatorThe taps and swipes use fixed coordinates, so other screen sizes need new ones.
- Bump
versioninpackage.jsonand add aCHANGELOG.mdentry. - Commit, then tag and push:
git tag v1.2.3 && git push origin main v1.2.3. - The
releaseworkflow checks the tag against the version, builds and publishes with trusted publishing: no token, and npm adds provenance on its own.
npm sets up a trusted publisher only for a package that already exists, so the first version goes
out by hand (npm login, then npm publish --access public). After that, add this repo and
release.yml as the trusted publisher on npmjs.com, and the tags take over. Each tag also gets a
GitHub release with its CHANGELOG.md entry.
Issues and pull requests are welcome. Run npm test, npm run typecheck and npm run lint before
opening one, and add a test for any behavior change. Visual changes on either platform are easiest
to review with a GIF from scripts/gifs.

