Kinetik UI

Time Wheel

.md

Wheels for a time of day: rows tilt away like a drum, each wheel snaps row by row with a tick.

v1.0.0Live on webreact-native-reanimatedreact-native-worklets
Component preview

Installation

Kinetik CLI
npx kinetik-ui add time-wheel

The 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

  1. Install dependencies
    npx expo install react-native-reanimated react-native-worklets
  2. Copy the source files
    • components/kinetik/time-wheel.tsx
    View source files

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.hours is always 0–23, also with hourCycle="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

PropTypeDefaultDescription
valuerequiredTimeValue—Hours 0–23 and minutes 0–59.
onChangerequired(value: TimeValue) => void—
minuteStepnumber1Minutes step by this much. Defaults to 1.
hourCycle'12h' | '24h''24h'"12h" adds an AM/PM wheel. Defaults to "24h".
accessibilityLabelstring'Time'Names the picker for screen readers. Defaults to "Time".
hapticsbooleantrueA tick for every row that passes the middle. Defaults to true.
testIDstring—
styleStyleProp<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 },
});