Time Wheel
Wheels for a time of day: rows tilt away like a drum, each wheel snaps row by row with a tick.
import { useEffect, useRef } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type TimeValue = { hours: number; minutes: number };
export type TimeWheelProps = {
/** Hours 0–23 and minutes 0–59. */
value: TimeValue;
onChange: (value: TimeValue) => void;
/** Minutes step by this much. Defaults to 1. */
minuteStep?: number;
/** "12h" adds an AM/PM wheel. Defaults to "24h". */
hourCycle?: '12h' | '24h';
/** Names the picker for screen readers. Defaults to "Time". */
accessibilityLabel?: string;
/** A tick for every row that passes the middle. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 40;
const VISIBLE = 5;
const HEIGHT = ROW * VISIBLE;
const pad = (n: number) => String(n).padStart(2, '0');
/**
* Wheels for a time of day, like a drum seen side-on. Rows tilt away and fade
* above and below the middle band, each wheel snaps row by row with a tick,
* and the value is set when it comes to rest.
*/
export function TimeWheel({
value,
onChange,
minuteStep = 1,
hourCycle = '24h',
accessibilityLabel = 'Time',
haptics = true,
testID,
style,
}: TimeWheelProps) {
const { colors, font, radius } = useKinetikTheme();
const twelve = hourCycle === '12h';
const step = Math.max(1, Math.min(30, Math.floor(minuteStep)));
const hours = twelve
? Array.from({ length: 12 }, (_, i) => (i === 0 ? 12 : i))
: Array.from({ length: 24 }, (_, i) => i);
const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step);
const pm = value.hours >= 12;
const hourIndex = twelve ? value.hours % 12 : value.hours;
const minuteIndex = Math.min(minutes.length - 1, Math.round(value.minutes / step));
const setHour = (i: number) => {
const h = twelve ? (i % 12) + (pm ? 12 : 0) : i;
if (h !== value.hours) onChange({ hours: h, minutes: value.minutes });
};
const setMinute = (i: number) => {
const m = minutes[i] ?? 0;
if (m !== value.minutes) onChange({ hours: value.hours, minutes: m });
};
const setHalf = (i: number) => {
const next = (value.hours % 12) + (i === 1 ? 12 : 0);
if (next !== value.hours) onChange({ hours: next, minutes: value.minutes });
};
return (
<View testID={testID} accessibilityLabel={accessibilityLabel} style={[styles.root, style]}>
<View
pointerEvents="none"
style={[styles.band, { backgroundColor: colors.surfaceRaised, borderRadius: radius.md }]}
/>
<Wheel
label={`${accessibilityLabel}, hours`}
testID={testID ? `${testID}-hours` : undefined}
items={hours.map((h) => (twelve ? String(h) : pad(h)))}
index={hourIndex}
onIndex={setHour}
haptics={haptics}
/>
<Text
style={[
styles.colon,
{ color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.semibold },
]}
accessibilityElementsHidden
importantForAccessibility="no"
>
:
</Text>
<Wheel
label={`${accessibilityLabel}, minutes`}
testID={testID ? `${testID}-minutes` : undefined}
items={minutes.map(pad)}
index={minuteIndex}
onIndex={setMinute}
haptics={haptics}
/>
{twelve ? (
<Wheel
label={`${accessibilityLabel}, AM or PM`}
items={['AM', 'PM']}
index={pm ? 1 : 0}
onIndex={setHalf}
haptics={haptics}
narrow
/>
) : null}
</View>
);
}
type WheelProps = {
label: string;
testID?: string;
items: readonly string[];
index: number;
onIndex: (index: number) => void;
haptics: boolean;
narrow?: boolean;
};
function Wheel({ label, items, index, onIndex, haptics, narrow, testID }: WheelProps) {
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const ref = useAnimatedRef<Animated.ScrollView>();
const y = useSharedValue(index * ROW);
const passing = useSharedValue(index);
const dragging = useSharedValue(false);
const last = items.length - 1;
// The first scroll to the value jumps (contentOffset on native; the web ignores
// that prop, so it is set again here). Later ones animate.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
ref.current?.scrollTo({ y: index * ROW, animated: false });
return;
}
if (dragging.get()) return;
if (Math.round(y.get() / ROW) === index) return;
ref.current?.scrollTo({ y: index * ROW, animated: !reduced });
}, [index, reduced, ref, y, dragging]);
const settle = (i: number) => onIndex(Math.max(0, Math.min(last, i)));
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
y.set(e.contentOffset.y);
const i = Math.max(0, Math.min(last, Math.round(e.contentOffset.y / ROW)));
if (i !== passing.get()) {
passing.set(i);
if (dragging.get()) scheduleOnRN(haptic, 'tick');
}
},
onBeginDrag: () => {
dragging.set(true);
},
onEndDrag: (e) => {
// Without a fling there is no momentum phase, so the wheel settles here.
if (Math.abs(e.velocity?.y ?? 0) < 0.05) {
dragging.set(false);
scheduleOnRN(settle, Math.round(e.contentOffset.y / ROW));
}
},
onMomentumEnd: (e) => {
dragging.set(false);
scheduleOnRN(settle, Math.round(e.contentOffset.y / ROW));
},
});
const nudge = (e: AccessibilityActionEvent) => {
const next = index + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
if (next >= 0 && next <= last) onIndex(next);
};
return (
<View
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ text: items[index] ?? '' }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={nudge}
style={[styles.wheel, narrow && styles.narrow]}
>
<Animated.ScrollView
ref={ref}
onScroll={onScroll}
scrollEventThrottle={16}
snapToInterval={ROW}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
contentOffset={{ x: 0, y: index * ROW }}
contentContainerStyle={styles.content}
nestedScrollEnabled
testID={testID}
>
{items.map((item, i) => (
<Row key={item} text={item} index={i} y={y} reduced={reduced} />
))}
</Animated.ScrollView>
</View>
);
}
function Row({
text,
index,
y,
reduced,
}: {
text: string;
index: number;
y: SharedValue<number>;
reduced: boolean;
}) {
const { colors, font } = useKinetikTheme();
const motion = useAnimatedStyle(() => {
const d = (index * ROW - y.get()) / ROW;
const a = Math.min(Math.abs(d), 3);
return {
opacity: 1 - a * 0.3,
transform: reduced
? []
: [
{ perspective: 500 },
{ rotateX: `${Math.max(-70, Math.min(70, -d * 24))}deg` },
{ scale: 1 - a * 0.05 },
],
};
});
return (
<Animated.View style={[styles.row, motion]}>
<Text
style={{
color: colors.text,
fontSize: font.size.xl,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: {
height: HEIGHT,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
},
band: { position: 'absolute', left: 0, right: 0, top: (HEIGHT - ROW) / 2, height: ROW },
wheel: { width: 72, height: HEIGHT },
narrow: { width: 64 },
content: { paddingVertical: (HEIGHT - ROW) / 2 },
row: { height: ROW, alignItems: 'center', justifyContent: 'center' },
colon: { marginTop: -3 },
});Installation
npx kinetik-ui add time-wheelpnpm dlx kinetik-ui add time-wheelbunx kinetik-ui add time-wheelyarn dlx kinetik-ui add time-wheelThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/time-wheel.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/time-wheel.tsx
Usage
import { TimeWheel } from '@/components/kinetik/time-wheel';
<TimeWheel
accessibilityLabel="Alarm"
value={time} // { hours: 7, minutes: 30 }
onChange={setTime}
minuteStep={5}
/>;When to use
- Setting a time of day: an alarm, a reminder, opening hours, a delivery window.
- Places where a thumb is quicker than typing digits.
When not to use
- Durations or countdown lengths beyond 24 hours. Use Roll Stepper or a number field.
- Picking a date. Use Month Calendar.
Notes
value.hoursis always 0–23, also withhourCycle="12h", which adds an AM/PM wheel.- Each wheel snaps row by row and ticks as rows pass the middle; the value is set when the wheel comes to rest.
- Every wheel is an adjustable element: screen readers can swipe up and down to step it.
- With reduced motion rows stay flat (no tilt) and programmatic changes jump instead of scrolling.
Props
TimeWheelProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | TimeValue | — | Hours 0–23 and minutes 0–59. |
onChangerequired | (value: TimeValue) => void | — | |
minuteStep | number | 1 | Minutes step by this much. Defaults to 1. |
hourCycle | '12h' | '24h' | '24h' | "12h" adds an AM/PM wheel. Defaults to "24h". |
accessibilityLabel | string | 'Time' | Names the picker for screen readers. Defaults to "Time". |
haptics | boolean | true | A tick for every row that passes the middle. Defaults to true. |
testID | string | — | |
style | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- Native scroll views; each row reads its wheel's scroll offset in one animated style. Nothing runs at rest.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 0.94× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 145 MB app memory. Janky frames come from the native fling and snap of the scroll views; rows stay at 17 ms median.
Real-device measurement pending.
Accessibility
- Reduced motion
- Rows stay flat and changes from outside jump instead of scrolling.
- Screen readers
- Each wheel is an adjustable element named after its part of the time, with the shown value.
- Touch target
- Each wheel is 72 by 200 pt.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/time-wheel.tsx
import { useEffect, useRef } from 'react';
import {
StyleSheet,
Text,
View,
type AccessibilityActionEvent,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
useAnimatedRef,
useAnimatedScrollHandler,
useAnimatedStyle,
useSharedValue,
type SharedValue,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type TimeValue = { hours: number; minutes: number };
export type TimeWheelProps = {
/** Hours 0–23 and minutes 0–59. */
value: TimeValue;
onChange: (value: TimeValue) => void;
/** Minutes step by this much. Defaults to 1. */
minuteStep?: number;
/** "12h" adds an AM/PM wheel. Defaults to "24h". */
hourCycle?: '12h' | '24h';
/** Names the picker for screen readers. Defaults to "Time". */
accessibilityLabel?: string;
/** A tick for every row that passes the middle. Defaults to true. */
haptics?: boolean;
testID?: string;
style?: StyleProp<ViewStyle>;
};
const ROW = 40;
const VISIBLE = 5;
const HEIGHT = ROW * VISIBLE;
const pad = (n: number) => String(n).padStart(2, '0');
/**
* Wheels for a time of day, like a drum seen side-on. Rows tilt away and fade
* above and below the middle band, each wheel snaps row by row with a tick,
* and the value is set when it comes to rest.
*/
export function TimeWheel({
value,
onChange,
minuteStep = 1,
hourCycle = '24h',
accessibilityLabel = 'Time',
haptics = true,
testID,
style,
}: TimeWheelProps) {
const { colors, font, radius } = useKinetikTheme();
const twelve = hourCycle === '12h';
const step = Math.max(1, Math.min(30, Math.floor(minuteStep)));
const hours = twelve
? Array.from({ length: 12 }, (_, i) => (i === 0 ? 12 : i))
: Array.from({ length: 24 }, (_, i) => i);
const minutes = Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step);
const pm = value.hours >= 12;
const hourIndex = twelve ? value.hours % 12 : value.hours;
const minuteIndex = Math.min(minutes.length - 1, Math.round(value.minutes / step));
const setHour = (i: number) => {
const h = twelve ? (i % 12) + (pm ? 12 : 0) : i;
if (h !== value.hours) onChange({ hours: h, minutes: value.minutes });
};
const setMinute = (i: number) => {
const m = minutes[i] ?? 0;
if (m !== value.minutes) onChange({ hours: value.hours, minutes: m });
};
const setHalf = (i: number) => {
const next = (value.hours % 12) + (i === 1 ? 12 : 0);
if (next !== value.hours) onChange({ hours: next, minutes: value.minutes });
};
return (
<View testID={testID} accessibilityLabel={accessibilityLabel} style={[styles.root, style]}>
<View
pointerEvents="none"
style={[styles.band, { backgroundColor: colors.surfaceRaised, borderRadius: radius.md }]}
/>
<Wheel
label={`${accessibilityLabel}, hours`}
testID={testID ? `${testID}-hours` : undefined}
items={hours.map((h) => (twelve ? String(h) : pad(h)))}
index={hourIndex}
onIndex={setHour}
haptics={haptics}
/>
<Text
style={[
styles.colon,
{ color: colors.text, fontSize: font.size.xl, fontWeight: font.weight.semibold },
]}
accessibilityElementsHidden
importantForAccessibility="no"
>
:
</Text>
<Wheel
label={`${accessibilityLabel}, minutes`}
testID={testID ? `${testID}-minutes` : undefined}
items={minutes.map(pad)}
index={minuteIndex}
onIndex={setMinute}
haptics={haptics}
/>
{twelve ? (
<Wheel
label={`${accessibilityLabel}, AM or PM`}
items={['AM', 'PM']}
index={pm ? 1 : 0}
onIndex={setHalf}
haptics={haptics}
narrow
/>
) : null}
</View>
);
}
type WheelProps = {
label: string;
testID?: string;
items: readonly string[];
index: number;
onIndex: (index: number) => void;
haptics: boolean;
narrow?: boolean;
};
function Wheel({ label, items, index, onIndex, haptics, narrow, testID }: WheelProps) {
const reduced = useReduceMotion();
const haptic = useHaptic(haptics);
const ref = useAnimatedRef<Animated.ScrollView>();
const y = useSharedValue(index * ROW);
const passing = useSharedValue(index);
const dragging = useSharedValue(false);
const last = items.length - 1;
// The first scroll to the value jumps (contentOffset on native; the web ignores
// that prop, so it is set again here). Later ones animate.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
ref.current?.scrollTo({ y: index * ROW, animated: false });
return;
}
if (dragging.get()) return;
if (Math.round(y.get() / ROW) === index) return;
ref.current?.scrollTo({ y: index * ROW, animated: !reduced });
}, [index, reduced, ref, y, dragging]);
const settle = (i: number) => onIndex(Math.max(0, Math.min(last, i)));
const onScroll = useAnimatedScrollHandler({
onScroll: (e) => {
y.set(e.contentOffset.y);
const i = Math.max(0, Math.min(last, Math.round(e.contentOffset.y / ROW)));
if (i !== passing.get()) {
passing.set(i);
if (dragging.get()) scheduleOnRN(haptic, 'tick');
}
},
onBeginDrag: () => {
dragging.set(true);
},
onEndDrag: (e) => {
// Without a fling there is no momentum phase, so the wheel settles here.
if (Math.abs(e.velocity?.y ?? 0) < 0.05) {
dragging.set(false);
scheduleOnRN(settle, Math.round(e.contentOffset.y / ROW));
}
},
onMomentumEnd: (e) => {
dragging.set(false);
scheduleOnRN(settle, Math.round(e.contentOffset.y / ROW));
},
});
const nudge = (e: AccessibilityActionEvent) => {
const next = index + (e.nativeEvent.actionName === 'increment' ? 1 : -1);
if (next >= 0 && next <= last) onIndex(next);
};
return (
<View
accessible
accessibilityRole="adjustable"
accessibilityLabel={label}
accessibilityValue={{ text: items[index] ?? '' }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={nudge}
style={[styles.wheel, narrow && styles.narrow]}
>
<Animated.ScrollView
ref={ref}
onScroll={onScroll}
scrollEventThrottle={16}
snapToInterval={ROW}
decelerationRate="fast"
showsVerticalScrollIndicator={false}
contentOffset={{ x: 0, y: index * ROW }}
contentContainerStyle={styles.content}
nestedScrollEnabled
testID={testID}
>
{items.map((item, i) => (
<Row key={item} text={item} index={i} y={y} reduced={reduced} />
))}
</Animated.ScrollView>
</View>
);
}
function Row({
text,
index,
y,
reduced,
}: {
text: string;
index: number;
y: SharedValue<number>;
reduced: boolean;
}) {
const { colors, font } = useKinetikTheme();
const motion = useAnimatedStyle(() => {
const d = (index * ROW - y.get()) / ROW;
const a = Math.min(Math.abs(d), 3);
return {
opacity: 1 - a * 0.3,
transform: reduced
? []
: [
{ perspective: 500 },
{ rotateX: `${Math.max(-70, Math.min(70, -d * 24))}deg` },
{ scale: 1 - a * 0.05 },
],
};
});
return (
<Animated.View style={[styles.row, motion]}>
<Text
style={{
color: colors.text,
fontSize: font.size.xl,
fontWeight: font.weight.medium,
fontVariant: ['tabular-nums'],
}}
>
{text}
</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
root: {
height: HEIGHT,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'center',
},
band: { position: 'absolute', left: 0, right: 0, top: (HEIGHT - ROW) / 2, height: ROW },
wheel: { width: 72, height: HEIGHT },
narrow: { width: 64 },
content: { paddingVertical: (HEIGHT - ROW) / 2 },
row: { height: ROW, alignItems: 'center', justifyContent: 'center' },
colon: { marginTop: -3 },
});