@iiyu/react-native-wheel-picker
v1.0.1
Published
A performant and customizable wheel picker for React Native and Expo.
Downloads
594
Maintainers
Readme
React Native Wheel Picker
A performant, customizable wheel picker built with React Native's core
Animated API. It works in Expo and React Native without native modules or a
config plugin. Use it as a single picker or compose multiple wheels into
controls such as a time picker.
Try it in Expo Go
Open the interactive example in Expo Snack, select My Device, and scan Snack's live QR code with Expo Go. Alternatively, scan the permanent QR below on iOS or Android and tap Open with Expo Go.
Installation
Install the package from npm:
npm install @iiyu/react-native-wheel-pickerReact >=19.1 and React Native >=0.81 are peer dependencies. Expo projects
already provide both.
Quick start
import { useState } from 'react';
import { Text } from 'react-native';
import { WheelPicker } from '@iiyu/react-native-wheel-picker';
const sizes = ['Small', 'Medium', 'Large'] as const;
export function SizePicker() {
const [selectedIndex, setSelectedIndex] = useState(1);
return (
<WheelPicker
accessibilityLabel="Size"
data={sizes}
selectedIndex={selectedIndex}
onValueChange={(_, index) => setSelectedIndex(index)}
renderItem={({ item }) => <Text>{item}</Text>}
/>
);
}Width and height props are not required. The picker stretches to its parent
and derives its viewport height from itemHeight (default 44) multiplied by
visibleItemCount (default 5).
Selection behavior
Use selectedIndex with onValueChange for controlled state, or
defaultSelectedIndex for uncontrolled state. Indexes are canonical so
primitive values, objects, duplicate values, and React elements are all
supported.
While a user drags or momentum is running, the row nearest the center
immediately receives visual selected styles. The public selection and
onValueChange update once scrolling settles. This matches the native iOS
distinction between visual focus and the committed value and avoids callback
updates on every scroll frame.
<WheelPicker
data={['One', 'Two', 'Three']}
defaultSelectedIndex={1}
onValueChange={(item, index) => {
console.log(item, index);
}}
/>For object data, provide a renderer and stable keys:
type Person = { id: string; name: string };
<WheelPicker<Person>
data={people}
keyExtractor={(person) => person.id}
renderItem={({ item, selected }) => (
<Text style={{ fontWeight: selected ? '700' : '400' }}>{item.name}</Text>
)}
/>;Strings, numbers, and valid React nodes have a default renderer. Plain objects
require renderItem. A custom renderer can combine text, avatars, badges, and
any other React content while the picker continues to handle centering,
selection, and accessibility.
Optional looping
Set loop to let a picker with two or more items scroll continuously in both
directions:
<WheelPicker
data={['Low', 'Medium', 'High']}
defaultSelectedIndex={1}
loop
onValueChange={(item, index) => {
console.log(item, index);
}}
/>Repeated rows are an internal positioning detail. selectedIndex,
onValueChange, renderItem, renderSelectionOverlay,
getItemAccessibilityLabel, and the imperative ref always use indexes from
the original data. Crossing from the last item to the first therefore
commits index 0, and crossing upward from the first commits
data.length - 1.
Empty and one-item pickers retain their normal finite, non-adjustable behavior
when loop is enabled. Finite scrolling remains the default.
Fixed row height
Every row has exactly the configured itemHeight. This is an intentional
invariant: deterministic snapping, center alignment, constant-time
getItemLayout, and bounded FlatList virtualization all depend on a fixed row
extent.
Custom content is centered and clipped inside that row. It may have any
internal layout, but it must not be expected to change the row's measured
height. Increase itemHeight if content or large text needs more vertical
space. Variable-height rows are not supported.
visibleItemCount must be a positive odd integer so one complete row remains
at the exact center. Invalid geometry warns in development and falls back to a
safe default.
API
Data and rendering
| Prop | Type | Default | Description |
| -------------- | ------------------------------------------ | -------------------------------------- | -------------------------------------------------------------------- |
| data | readonly T[] | required | Items rendered by the picker. |
| renderItem | ({ item, index, selected }) => ReactNode | primitive/React-node renderer | Renders a row. selected follows the row nearest the visual center. |
| keyExtractor | (item, index) => string | primitive value, element key, or index | Supplies stable, unique FlatList keys. |
| renderEmpty | () => ReactNode | default empty message | Replaces the empty state. |
Selection and interaction
| Prop | Type | Default | Description |
| ---------------------- | ------------------------------ | -------------- | ------------------------------------------------- |
| selectedIndex | number | — | Controlled committed index. |
| defaultSelectedIndex | number | 0 | Initial uncontrolled index. |
| onValueChange | (item, index) => void | — | Fires once when a new selection settles. |
| enabled | boolean | true | Enables drag, tap, and accessibility adjustment. |
| animated | boolean | true | Default for programmatic selection transitions. |
| loop | boolean | false | Repeats multi-item data for continuous scrolling. |
| tapToSelect | boolean | false | Centers a visible row when it is pressed. |
| decelerationRate | number \| 'normal' \| 'fast' | 'fast' | Controls native scroll deceleration. |
| reducedMotion | boolean | system setting | Overrides the system reduce-motion preference. |
Geometry, slots, and style
| Prop | Type | Default | Description |
| ------------------------ | ---------------------------- | ---------------- | -------------------------------------------------- |
| itemHeight | number | 44 | Fixed height and snap interval for every row. |
| visibleItemCount | number | 5 | Positive odd number of visible row extents. |
| header / footer | ReactNode | — | Natural-height content outside the wheel viewport. |
| renderSelectionOverlay | (info) => ReactNode | default band | Replaces the centered, non-interactive overlay. |
| style | StyleProp<ViewStyle> | — | Root style. |
| viewportStyle | StyleProp<ViewStyle> | — | Wheel viewport style. |
| contentContainerStyle | StyleProp<ViewStyle> | — | FlatList content style. |
| styles | Partial<WheelPickerStyles> | — | Named style slots described below. |
| animation | WheelPickerAnimationConfig | platform preset | Overrides the wheel-depth treatment. |
| testID | string | 'wheel-picker' | Test identifier applied to the root. |
Accessibility
| Prop | Type | Default | Description |
| --------------------------- | ------------------------- | ---------------------- | ---------------------------------------------------------- |
| accessibilityLabel | string | 'Wheel picker' | Labels the adjustable control. |
| accessibilityHint | string | — | Adds screen-reader instructions. |
| getItemAccessibilityLabel | (item, index) => string | primitive string value | Produces the committed value announced by a screen reader. |
Animation configuration
animation accepts:
| Field | Description |
| ---------------- | ------------------------------------------------------------- |
| perspective | Positive camera distance for the 3D transform. Default 900. |
| maxRotation | Maximum absolute rotateX angle in degrees. |
| minimumOpacity | Edge opacity from 0 to 1. |
| minimumScale | Edge scale from 0 to 1. |
Rows continue fading, scaling, rotating, and cylindrically compressing toward the viewport edges. Reduced motion suppresses those scroll-linked effects.
Imperative ref
import { createRef } from 'react';
import {
WheelPicker,
type WheelPickerRef,
} from '@iiyu/react-native-wheel-picker';
const pickerRef = createRef<WheelPickerRef>();
<WheelPicker ref={pickerRef} data={['One', 'Two']} />;
pickerRef.current?.scrollToIndex(1);
pickerRef.current?.scrollToIndex(0, { animated: false });
const selectedIndex = pickerRef.current?.getSelectedIndex();Imperative indexes always refer to the original data and out-of-range values
are clamped to that range. In loop mode, the picker animates to the nearest
repeated occurrence, so programmatic changes across the first/last boundary
take the short path. The package also exports DEFAULT_ITEM_HEIGHT and
DEFAULT_VISIBLE_ITEM_COUNT.
Styling
The styles prop supports every visual layer:
container,viewport, andcontentContainerheaderContainerandfooterContaineritemContainerandselectedItemContainertextandselectedTextselectionOverlayemptyContainerandemptyText
<WheelPicker
data={['Small', 'Medium', 'Large']}
header={<Text>Size</Text>}
footer={<Text>Choose one option</Text>}
tapToSelect
animation={{
perspective: 900,
maxRotation: 62,
minimumOpacity: 0.12,
minimumScale: 0.78,
}}
styles={{
selectionOverlay: {
backgroundColor: '#eef2ff',
borderColor: '#c7d2fe',
borderWidth: 1,
},
selectedText: { color: '#312e81' },
}}
/>renderSelectionOverlay receives the visual selectedIndex, selectedItem,
itemHeight, and viewportHeight. The overlay uses pointerEvents="none" so
it never blocks dragging.
Accessibility and reduced motion
The viewport is one adjustable VoiceOver/TalkBack control. Increment and
decrement actions use the same deduplicated selection path as touch and
imperative scrolling. They stop at finite boundaries and wrap when loop is
enabled. Off-center rows are hidden from the accessibility tree to prevent
noisy announcements.
The picker listens to the system reduce-motion preference. When active,
scroll-linked opacity and 3D transforms remain static and programmatic
selection is immediate. Use reducedMotion only for an application-specific
override.
For large text, choose an itemHeight that fits the rendered text at the
largest supported font scale; row height does not grow automatically.
Performance
Opacity and transforms are connected to native scrolling with
useNativeDriver: true. JavaScript state changes only at center-threshold
crossings, while public selection changes only at settlement.
The picker uses fixed getItemLayout geometry, bounded rendering batches, and
a virtualized FlatList window. removeClippedSubviews is deliberately disabled
because it can hide transformed rows on Android. Keep data, renderItem,
keyExtractor, style objects, and label callbacks stable so memoized rows can
skip unrelated renders.
Loop mode uses an odd, middle-anchored repeated window. Ordinary boundary crossings keep the exact physical row that settled, avoiding a visible reset; the list normalizes to the equivalent middle row only when it reaches a distant edge guard. Small data sets receive enough repeated references to keep a fast fling away from an edge; large data sets use three copies. FlatList still renders only its bounded virtualized window.
The Expo 10k items example demonstrates large-data positioning and jumps to the first, middle, and final row.
Troubleshooting
A controlled picker returns to the old row
Update selectedIndex in onValueChange. Controlled props remain
authoritative after a drag settles.
The highlighted row changes before my application value
This is expected. Highlighting follows the row nearest the center during motion; the callback and controlled value change only after motion stops.
Object rows are blank or keys warn
Plain objects need renderItem. Supply a keyExtractor that returns a stable,
unique key, especially when values may repeat.
Custom content is clipped or rows look uneven
All rows use the same itemHeight. Increase it to fit the tallest content and
avoid vertical margins that imply a different external row height.
The center is between two rows
Use a positive odd visibleItemCount. Even and invalid values fall back to the
default in development with a warning.
Dragging is disabled
Check that enabled is not false and that no parent gesture responder or
absolute overlay is intercepting touches. Custom selection overlays provided
through this component cannot intercept touches.
Looping does not move a one-item picker
This is intentional. Continuous scrolling only becomes effective when data
contains at least two items; empty and one-item pickers remain non-adjustable.
Metro resolves the repository instead of the packed package
Do not validate publication through the example's file:.. dependency alone.
Run npm run test:expo-fixture; it packs the artifact, installs it into an
isolated Expo app, and generates production iOS and Android Metro bundles.
Example app
npm install
npm run example:startOr launch a local platform:
npm run example:ios
npm run example:androidThe showcase includes basics, a composed time picker, custom JSX, finite and looping modes in the customization playground, 10,000 items, light/dark themes, and accessibility and reduced-motion behavior.
Development and release validation
npm install
npm run check
npm run release:checkcheck runs formatting, linting, root/example type checking, tests, package
builds, consumer declaration compilation, and export verification.
release:check additionally inspects the npm tarball and installs the real
packed artifact into a clean Expo fixture before producing minified iOS and
Android Metro bundles.
Set KEEP_EXPO_FIXTURE=1 when debugging the generated fixture.
