iOS-style spinning wheel picker with real fling physics — a pan drives the 3D drum, release velocity coasts on decay and snaps to the nearest detent with a slight overshoot, edges rubber-band, and a selection haptic ticks per detent.
components/ui/drum-picker/drum-picker.tsxcomponents/ui/drum-picker/wheel.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-gesture-handler@*expo-haptics@*| options | DrumPickerOption[] | Plain strings, or { label, value } pairs when the display text differs from the emitted value. |
| value? | string | Controlled selected value. |
| defaultValue? | string | Uncontrolled initial value. |
| onValueChange? | (value: string) => void | Fires once per settle — when the wheel lands on a new row, not per frame. |
| itemHeight? | number | Height of one row in pt. Default 44. |
| visibleItems? | number | Visible rows (odd looks best). Default 5. |
| textClassName? | string | Classes for option text. |
| hapticTicks? | boolean | Haptic tick per detent. Default true. |
| disabled? | boolean | |
| className? | string |
iOS-style spinning wheel picker with real fling physics — a pan drives the 3D drum, release velocity coasts on decay and snaps to the nearest detent with a slight overshoot, edges rubber-band, and a selection haptic ticks per detent.
import { DrumPicker } from "@/components/ui/drum-picker/drum-picker";
export function Example() {
return (
<DrumPicker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import { cn } from "@lumioui/core";
import { WheelColumn } from "./wheel";
/**
* DrumPicker — PRO signature component. The iOS-style spinning wheel:
* items ride on a 3D drum (rotateX + fade as they recede), a real fling
* coasts on release velocity and snaps onto the nearest detent with a
* slight overshoot, the edges rubber-band, and each detent crossing fires
* a selection haptic — it *ticks* under your finger like the real thing.
*
* ```tsx
* <DrumPicker
* options={["AM", "PM"]}
* value={meridiem}
* onValueChange={setMeridiem}
* />
* ```
*/
export type DrumPickerOption = string | { label: string; value: string };
export interface DrumPickerProps extends ViewProps {
/** Plain strings, or { label, value } pairs when the display text differs
* from the emitted value. */
options: DrumPickerOption[];
/** Controlled selected value. */
value?: string;
/** Uncontrolled initial value. */
defaultValue?: string;
/** Fires once per settle — when the wheel lands on a new row, not per frame. */
onValueChange?: (value: string) => void;
/** Height of one row in pt. Default 44. */
itemHeight?: number;
/** Visible rows (odd looks best). Default 5. */
visibleItems?: number;
/** Classes for option text. */
textClassName?: string;
/** Haptic tick per detent. Default true. */
hapticTicks?: boolean;
disabled?: boolean;
className?: string;
}
const optionValue = (option: DrumPickerOption) =>
typeof option === "string" ? option : option.value;
const optionLabel = (option: DrumPickerOption) =>
typeof option === "string" ? option : option.label;
export const DrumPicker = forwardRef<View, DrumPickerProps>(function DrumPicker(
{
options,
value,
defaultValue,
onValueChange,
itemHeight = 44,
visibleItems = 5,
textClassName,
hapticTicks = true,
disabled = false,
className,
...props
}: DrumPickerProps,
ref
) {
const labels = React.useMemo(() => options.map(optionLabel), [options]);
const values = React.useMemo(() => options.map(optionValue), [options]);
// Controlled + uncontrolled: `value` wins when provided, otherwise the
// wheel owns its state seeded from `defaultValue`.
const controlled = value !== undefined;
const [internal, setInternal] = React.useState<string | undefined>(
() => defaultValue ?? value
);
const currentValue = controlled ? value : internal;
const found = currentValue === undefined ? -1 : values.indexOf(currentValue);
const selectedIndex = found < 0 ? 0 : found;
const pad = itemHeight * Math.floor(visibleItems / 2);
const handleSelect = React.useCallback(
(i: number) => {
const v = values[i];
if (v === undefined) return;
if (!controlled) setInternal(v);
onValueChange?.(v);
},
[values, controlled, onValueChange]
);
return (
<View ref={ref} className={cn("overflow-hidden", className)} {...props}>
{/* Selection band — sits under the rows (translucent), aligned with the
wheel's center detent. */}
<View
pointerEvents="none"
style={{ top: pad, height: itemHeight }}
className="absolute left-2 right-2 rounded-xl bg-muted/60"
/>
<WheelColumn
items={labels}
selectedIndex={selectedIndex}
onSelect={handleSelect}
itemHeight={itemHeight}
visibleItems={visibleItems}
hapticTicks={hapticTicks}
disabled={disabled}
depth="drum"
label="Picker"
textClassName={textClassName}
/>
</View>
);
});
DrumPicker.displayName = "DrumPicker";