iOS-style wheel time picker — hour/minute columns with real fling physics (pan + decay coast + detent snap + rubber-band edges) in a spring sheet with deferred unmount and configurable minute step
components/ui/time-picker/time-picker.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0react-native-safe-area-context@*react-native-gesture-handler@*expo-haptics@*Live preview — the real component, driven by the props below.
import { TimePicker } from "@/components/ui/time-picker/time-picker";
export function Example() {
return (
<TimePicker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Modal, Pressable, Text, View, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, useReducedMotion, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { WheelColumn as WheelColumnBase } from "../drum-picker/wheel";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* TimePicker — PRO component. iOS-style wheel time picker: hour/minute
* columns are gesture-driven drums — pan to scroll, fling coasts on decay
* and snaps to the detent with a slight overshoot, edges rubber-band, and
* each detent ticks a selection haptic. Presented in a spring sheet.
* Requires react-native-gesture-handler (dev build, not Expo Go).
*/
export interface TimeValue {
hour: number; // 0-23
minute: number; // 0-59
}
export interface TimePickerProps extends Omit<ViewProps, "className"> {
open: boolean;
onOpenChange: (open: boolean) => void;
value?: TimeValue;
/** Uncontrolled initial value. Default { hour: 9, minute: 0 }. */
defaultValue?: TimeValue;
onChange?: (value: TimeValue) => void;
/** Minute increments in the wheel. Default 1. */
minuteStep?: number;
/** Use 12-hour wheel with AM/PM column. Default false (24h). */
use12Hour?: boolean;
className?: string;
}
const ITEM_H = 44;
const VISIBLE = 5;
const PAD = ITEM_H * Math.floor(VISIBLE / 2);
/** Offscreen park position for the sheet's translateY (below the fold). */
const SHEET_HIDDEN = 500;
interface WheelColumnProps {
items: string[];
selectedIndex: number;
onSelect: (index: number) => void;
label?: string;
}
/** One picker column — shared wheel physics (fling/snap/rubber-band) with
* the flatter scale+fade depth the time sheet has always used. */
function WheelColumn({ items, selectedIndex, onSelect, label }: WheelColumnProps) {
return (
<WheelColumnBase
items={items}
selectedIndex={selectedIndex}
onSelect={onSelect}
label={label}
depth="flat"
itemHeight={ITEM_H}
visibleItems={VISIBLE}
selectedTextClassName="font-semibold text-foreground"
unselectedTextClassName="text-muted-foreground"
className="flex-1"
/>
);
}
export const TimePicker = forwardRef<View, TimePickerProps>(function TimePicker(
{
open,
onOpenChange,
value,
defaultValue,
onChange,
minuteStep = 1,
use12Hour = false,
className,
...props
}: TimePickerProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const translateY = useSharedValue(SHEET_HIDDEN);
// Backdrop fade — the Modal itself is animationType="none" so this
// Reanimated opacity layer is the enter/exit transition for the scrim.
const backdrop = useSharedValue(0);
const confirmPress = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.85 });
// Keeps the Modal mounted while the exit animation runs — `visible={open}`
// unmounts instantly and the slide-down never gets a frame on screen.
const [rendered, setRendered] = React.useState(open);
// Distinguishes a real open→close transition from the re-render that
// `setRendered` triggers — only state flips drive animations.
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (open) {
if (!wasOpen.current) {
setRendered(true);
translateY.value = reduce ? 0 : withSpring(0, spring.surface);
backdrop.value = reduce ? 1 : withTiming(1, timing.normal);
}
} else if (wasOpen.current) {
if (reduce) {
translateY.value = SHEET_HIDDEN;
backdrop.value = 0;
setRendered(false);
} else {
// Exits run faster than entrances: timing.fast slide-down vs the
// spring.surface entrance, recede for the scrim.
translateY.value = withTiming(SHEET_HIDDEN, timing.fast);
backdrop.value = withTiming(0, timing.recede, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, translateY, backdrop, reduce]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: backdrop.value,
}));
const step = Math.max(1, Math.floor(minuteStep));
// Snap a raw value onto the step grid and clamp to the clock range.
const resolveValue = React.useCallback(
(v?: TimeValue): TimeValue => {
const base = v ?? defaultValue ?? { hour: 9, minute: 0 };
const minute = Math.max(0, Math.min(59, base.minute));
return {
hour: Math.max(0, Math.min(23, base.hour)),
minute: minute - (minute % step),
};
},
[defaultValue, step]
);
const initial = resolveValue(value);
const [draft, setDraft] = React.useState<TimeValue>(initial);
const [period, setPeriod] = React.useState<"AM" | "PM">(
initial.hour >= 12 ? "PM" : "AM"
);
React.useEffect(() => {
if (open) {
const v = resolveValue(value);
setDraft(v);
setPeriod(v.hour >= 12 ? "PM" : "AM");
}
}, [open, value, resolveValue]);
const minutes = React.useMemo(() => {
const arr: number[] = [];
for (let m = 0; m < 60; m += step) arr.push(m);
return arr;
}, [step]);
const minuteIndex = Math.max(0, minutes.indexOf(draft.minute));
const hourItems = React.useMemo(() => {
if (use12Hour) return Array.from({ length: 12 }, (_, i) => `${i + 1}`);
return Array.from({ length: 24 }, (_, i) => `${i}`.padStart(2, "0"));
}, [use12Hour]);
const hourIndex = use12Hour
? (draft.hour % 12 === 0 ? 12 : draft.hour % 12) - 1
: draft.hour;
const minuteItems = React.useMemo(
() => minutes.map((m) => `${m}`.padStart(2, "0")),
[minutes]
);
const confirm = () => {
haptics.light();
let hour = draft.hour;
if (use12Hour) {
const h12 = hour % 12; // 0-11 displayed 1-12 already stored as index+1
hour = period === "PM" ? (h12 % 12) + 12 : h12 % 12;
}
onChange?.({ hour, minute: draft.minute });
onOpenChange(false);
};
return (
<Modal
visible={rendered}
transparent
// "none": the scrim fade runs on the Reanimated `backdrop` layer below,
// consistent with the other overlays in this package.
animationType="none"
onRequestClose={() => { haptics.light(); onOpenChange(false); }}
>
<View style={{ width: "100%", height: "100%" }}>
<Pressable
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
onPress={() => { haptics.light(); onOpenChange(false); }}
accessibilityLabel="Close time picker" accessibilityRole="button"
>
<Animated.View
style={[{ width: "100%", height: "100%", backgroundColor: colors.overlay }, backdropStyle]}
/>
</Pressable>
<Animated.View
ref={ref}
style={[{ backgroundColor: colors.card, paddingBottom: Math.max(40, insets.bottom + 16) }, sheetStyle]}
className={cn("absolute bottom-0 w-full max-w-[480px] self-center rounded-t-2xl p-5", className)}
accessibilityViewIsModal
accessibilityRole="alert"
accessibilityLabel="Time picker"
{...props}
>
<View className="mx-auto mb-4 h-1 w-10 rounded-full bg-border" />
<Text className="pb-4 text-center text-lg font-bold leading-tight text-card-foreground">
Select time
</Text>
<View className="relative flex-row">
{/* Center highlight band */}
<View
pointerEvents="none"
style={{ top: PAD, height: ITEM_H }}
className="absolute left-0 right-0 rounded-xl bg-accent"
/>
<WheelColumn
label="Hour"
items={hourItems}
selectedIndex={hourIndex}
onSelect={(i) => {
if (use12Hour) {
setDraft((d) => ({ ...d, hour: i + 1 }));
} else {
setDraft((d) => ({ ...d, hour: i }));
}
}}
/>
<WheelColumn
label="Minute"
items={minuteItems}
selectedIndex={minuteIndex}
onSelect={(i) => setDraft((d) => ({ ...d, minute: minutes[i] ?? 0 }))}
/>
{use12Hour && (
<WheelColumn
label="Period"
items={["AM", "PM"]}
selectedIndex={period === "AM" ? 0 : 1}
onSelect={(i) => setPeriod(i === 0 ? "AM" : "PM")}
/>
)}
</View>
<AnimatedPressable
onPress={confirm}
onPressIn={() => {
confirmPress.onPressIn();
}}
onPressOut={() => {
confirmPress.onPressOut();
}}
style={confirmPress.animatedStyle}
className="mt-6 h-12 items-center justify-center overflow-hidden rounded-full bg-primary"
accessibilityRole="button"
accessibilityLabel={`Confirm time ${hourItems[hourIndex] ?? ""}:${minuteItems[minuteIndex] ?? ""}${use12Hour ? ` ${period}` : ""}`}
>
<Text className="font-semibold leading-tight text-primary-foreground">Confirm</Text>
</AnimatedPressable>
</Animated.View>
</View>
</Modal>
);
});
TimePicker.displayName = "TimePicker";
| open | boolean | ||
| onOpenChange | (open: boolean) => void | ||
| value? | TimeValue | ||
| defaultValue? | TimeValue | Uncontrolled initial value. Default { hour: 9, minute: 0 }. | |
| onChange? | (value: TimeValue) => void | ||
| minuteStep? | number | Minute increments in the wheel. Default 1. | |
| use12Hour? | boolean | Use 12-hour wheel with AM/PM column. Default false (24h). | |
| className? | string |
iOS-style wheel time picker — hour/minute columns with real fling physics (pan + decay coast + detent snap + rubber-band edges) in a spring sheet with deferred unmount and configurable minute step
import { TimePicker } from "@/components/ui/time-picker/time-picker";
export function Example() {
return (
<TimePicker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Modal, Pressable, Text, View, type ViewProps, } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
import { cn, useHaptic, spring, timing, useReducedMotion, useTheme, usePressAnimation, AnimatedPressable } from "@lumioui/core";
import { WheelColumn as WheelColumnBase } from "../drum-picker/wheel";
import { useSafeAreaInsets } from "react-native-safe-area-context";
/**
* TimePicker — PRO component. iOS-style wheel time picker: hour/minute
* columns are gesture-driven drums — pan to scroll, fling coasts on decay
* and snaps to the detent with a slight overshoot, edges rubber-band, and
* each detent ticks a selection haptic. Presented in a spring sheet.
* Requires react-native-gesture-handler (dev build, not Expo Go).
*/
export interface TimeValue {
hour: number; // 0-23
minute: number; // 0-59
}
export interface TimePickerProps extends Omit<ViewProps, "className"> {
open: boolean;
onOpenChange: (open: boolean) => void;
value?: TimeValue;
/** Uncontrolled initial value. Default { hour: 9, minute: 0 }. */
defaultValue?: TimeValue;
onChange?: (value: TimeValue) => void;
/** Minute increments in the wheel. Default 1. */
minuteStep?: number;
/** Use 12-hour wheel with AM/PM column. Default false (24h). */
use12Hour?: boolean;
className?: string;
}
const ITEM_H = 44;
const VISIBLE = 5;
const PAD = ITEM_H * Math.floor(VISIBLE / 2);
/** Offscreen park position for the sheet's translateY (below the fold). */
const SHEET_HIDDEN = 500;
interface WheelColumnProps {
items: string[];
selectedIndex: number;
onSelect: (index: number) => void;
label?: string;
}
/** One picker column — shared wheel physics (fling/snap/rubber-band) with
* the flatter scale+fade depth the time sheet has always used. */
function WheelColumn({ items, selectedIndex, onSelect, label }: WheelColumnProps) {
return (
<WheelColumnBase
items={items}
selectedIndex={selectedIndex}
onSelect={onSelect}
label={label}
depth="flat"
itemHeight={ITEM_H}
visibleItems={VISIBLE}
selectedTextClassName="font-semibold text-foreground"
unselectedTextClassName="text-muted-foreground"
className="flex-1"
/>
);
}
export const TimePicker = forwardRef<View, TimePickerProps>(function TimePicker(
{
open,
onOpenChange,
value,
defaultValue,
onChange,
minuteStep = 1,
use12Hour = false,
className,
...props
}: TimePickerProps,
ref
) {
const haptics = useHaptic();
const { colors } = useTheme();
const insets = useSafeAreaInsets();
const reduce = useReducedMotion();
const translateY = useSharedValue(SHEET_HIDDEN);
// Backdrop fade — the Modal itself is animationType="none" so this
// Reanimated opacity layer is the enter/exit transition for the scrim.
const backdrop = useSharedValue(0);
const confirmPress = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.85 });
// Keeps the Modal mounted while the exit animation runs — `visible={open}`
// unmounts instantly and the slide-down never gets a frame on screen.
const [rendered, setRendered] = React.useState(open);
// Distinguishes a real open→close transition from the re-render that
// `setRendered` triggers — only state flips drive animations.
const wasOpen = React.useRef(false);
React.useEffect(() => {
if (open) {
if (!wasOpen.current) {
setRendered(true);
translateY.value = reduce ? 0 : withSpring(0, spring.surface);
backdrop.value = reduce ? 1 : withTiming(1, timing.normal);
}
} else if (wasOpen.current) {
if (reduce) {
translateY.value = SHEET_HIDDEN;
backdrop.value = 0;
setRendered(false);
} else {
// Exits run faster than entrances: timing.fast slide-down vs the
// spring.surface entrance, recede for the scrim.
translateY.value = withTiming(SHEET_HIDDEN, timing.fast);
backdrop.value = withTiming(0, timing.recede, (finished) => {
if (finished) runOnJS(setRendered)(false);
});
}
}
wasOpen.current = open;
}, [open, translateY, backdrop, reduce]);
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: backdrop.value,
}));
const step = Math.max(1, Math.floor(minuteStep));
// Snap a raw value onto the step grid and clamp to the clock range.
const resolveValue = React.useCallback(
(v?: TimeValue): TimeValue => {
const base = v ?? defaultValue ?? { hour: 9, minute: 0 };
const minute = Math.max(0, Math.min(59, base.minute));
return {
hour: Math.max(0, Math.min(23, base.hour)),
minute: minute - (minute % step),
};
},
[defaultValue, step]
);
const initial = resolveValue(value);
const [draft, setDraft] = React.useState<TimeValue>(initial);
const [period, setPeriod] = React.useState<"AM" | "PM">(
initial.hour >= 12 ? "PM" : "AM"
);
React.useEffect(() => {
if (open) {
const v = resolveValue(value);
setDraft(v);
setPeriod(v.hour >= 12 ? "PM" : "AM");
}
}, [open, value, resolveValue]);
const minutes = React.useMemo(() => {
const arr: number[] = [];
for (let m = 0; m < 60; m += step) arr.push(m);
return arr;
}, [step]);
const minuteIndex = Math.max(0, minutes.indexOf(draft.minute));
const hourItems = React.useMemo(() => {
if (use12Hour) return Array.from({ length: 12 }, (_, i) => `${i + 1}`);
return Array.from({ length: 24 }, (_, i) => `${i}`.padStart(2, "0"));
}, [use12Hour]);
const hourIndex = use12Hour
? (draft.hour % 12 === 0 ? 12 : draft.hour % 12) - 1
: draft.hour;
const minuteItems = React.useMemo(
() => minutes.map((m) => `${m}`.padStart(2, "0")),
[minutes]
);
const confirm = () => {
haptics.light();
let hour = draft.hour;
if (use12Hour) {
const h12 = hour % 12; // 0-11 displayed 1-12 already stored as index+1
hour = period === "PM" ? (h12 % 12) + 12 : h12 % 12;
}
onChange?.({ hour, minute: draft.minute });
onOpenChange(false);
};
return (
<Modal
visible={rendered}
transparent
// "none": the scrim fade runs on the Reanimated `backdrop` layer below,
// consistent with the other overlays in this package.
animationType="none"
onRequestClose={() => { haptics.light(); onOpenChange(false); }}
>
<View style={{ width: "100%", height: "100%" }}>
<Pressable
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
onPress={() => { haptics.light(); onOpenChange(false); }}
accessibilityLabel="Close time picker" accessibilityRole="button"
>
<Animated.View
style={[{ width: "100%", height: "100%", backgroundColor: colors.overlay }, backdropStyle]}
/>
</Pressable>
<Animated.View
ref={ref}
style={[{ backgroundColor: colors.card, paddingBottom: Math.max(40, insets.bottom + 16) }, sheetStyle]}
className={cn("absolute bottom-0 w-full max-w-[480px] self-center rounded-t-2xl p-5", className)}
accessibilityViewIsModal
accessibilityRole="alert"
accessibilityLabel="Time picker"
{...props}
>
<View className="mx-auto mb-4 h-1 w-10 rounded-full bg-border" />
<Text className="pb-4 text-center text-lg font-bold leading-tight text-card-foreground">
Select time
</Text>
<View className="relative flex-row">
{/* Center highlight band */}
<View
pointerEvents="none"
style={{ top: PAD, height: ITEM_H }}
className="absolute left-0 right-0 rounded-xl bg-accent"
/>
<WheelColumn
label="Hour"
items={hourItems}
selectedIndex={hourIndex}
onSelect={(i) => {
if (use12Hour) {
setDraft((d) => ({ ...d, hour: i + 1 }));
} else {
setDraft((d) => ({ ...d, hour: i }));
}
}}
/>
<WheelColumn
label="Minute"
items={minuteItems}
selectedIndex={minuteIndex}
onSelect={(i) => setDraft((d) => ({ ...d, minute: minutes[i] ?? 0 }))}
/>
{use12Hour && (
<WheelColumn
label="Period"
items={["AM", "PM"]}
selectedIndex={period === "AM" ? 0 : 1}
onSelect={(i) => setPeriod(i === 0 ? "AM" : "PM")}
/>
)}
</View>
<AnimatedPressable
onPress={confirm}
onPressIn={() => {
confirmPress.onPressIn();
}}
onPressOut={() => {
confirmPress.onPressOut();
}}
style={confirmPress.animatedStyle}
className="mt-6 h-12 items-center justify-center overflow-hidden rounded-full bg-primary"
accessibilityRole="button"
accessibilityLabel={`Confirm time ${hourItems[hourIndex] ?? ""}:${minuteItems[minuteIndex] ?? ""}${use12Hour ? ` ${period}` : ""}`}
>
<Text className="font-semibold leading-tight text-primary-foreground">Confirm</Text>
</AnimatedPressable>
</Animated.View>
</View>
</Modal>
);
});
TimePicker.displayName = "TimePicker";