Time Picker

A completely custom-built time picker that features both Single Time and Time Range selection modes. It includes a scroll-driven animated analog clock face whose hands perfectly sync with the scroll position of the digital time lists using React Native Reanimated spring physics.

v
Updated

Live Preview

Open in Snack
Preview

Installation

1
npm install react-native-reanimated
2

Copy TimePicker.tsx to your components folder

3

Import and use

Source Code

TimePicker.tsx
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
  View,
  Text,
  StyleSheet,
  TouchableOpacity,
  Pressable,
  Platform,
  Dimensions,
} from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
  interpolate,
  Extrapolation,
  useAnimatedScrollHandler,
  useDerivedValue,
  runOnJS,
  useAnimatedRef,
} from 'react-native-reanimated';

// ─── DATA GENERATION ────────────────────────────────────────────────────────

const ITEM_HEIGHT = 44;
const VISIBLE_ITEMS = 5;

const TIMES = Array.from({ length: 48 }, (_, i) => {
  const totalMinutes = i * 30;
  const h24 = Math.floor(totalMinutes / 60);
  const m = totalMinutes % 60;
  const h12 = h24 === 0 ? 12 : h24 > 12 ? h24 - 12 : h24;
  const ampm = h24 < 12 ? 'am' : 'pm';
  return {
    id: `time-${i}`,
    label: `${h12}:${m === 0 ? '00' : m} ${ampm}`,
    index: i,
  };
});

// ─── ICONS ──────────────────────────────────────────────────────────────────

function ClockIcon({ color, size = 18 }: { color: string; size?: number }) {
  return (
    <View
      style={{
        width: size,
        height: size,
        borderRadius: size / 2,
        borderWidth: 1.5,
        borderColor: color,
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      <View
        style={{
          width: 1.5,
          height: size * 0.25,
          backgroundColor: color,
          position: 'absolute',
          top: size * 0.15,
          borderRadius: 1,
        }}
      />
      <View
        style={{
          width: size * 0.2,
          height: 1.5,
          backgroundColor: color,
          position: 'absolute',
          top: size * 0.4,
          left: size * 0.4,
          borderRadius: 1,
        }}
      />
    </View>
  );
}

// ─── SUBCOMPONENTS ──────────────────────────────────────────────────────────

interface TimeItemProps {
  item: typeof TIMES[0];
  index: number;
  activeIndexContinuous: any;
  textPrimary: string;
}

const TimeItem = ({ item, index, activeIndexContinuous, textPrimary }: TimeItemProps) => {
  const itemStyle = useAnimatedStyle(() => {
    const distance = Math.abs(activeIndexContinuous.value - index);
    const scale = interpolate(distance, [0, 1, 2], [1.1, 0.9, 0.8], Extrapolation.CLAMP);
    const opacity = interpolate(distance, [0, 1, 2], [1, 0.5, 0.2], Extrapolation.CLAMP);
    return { transform: [{ scale }], opacity };
  });

  return (
    <Animated.View style={[styles.timeItem, itemStyle]}>
      <Text style={[styles.timeText, { color: textPrimary }]}>{item.label}</Text>
    </Animated.View>
  );
};

// ─── MAIN COMPONENT ─────────────────────────────────────────────────────────

export interface TimePickerProps {
  isDarkMode?: boolean;
  initialIndex?: number;
  onTimeChange?: (start: string, end?: string) => void;
}

export default function TimePicker({
  isDarkMode = true,
  initialIndex = 38, // Default 7:00 pm
  onTimeChange,
}: TimePickerProps) {
  const [isExpanded, setIsExpanded] = useState(false);
  const [selectionMode, setSelectionMode] = useState<'single' | 'range'>('single');
  
  const [startLabel, setStartLabel] = useState(TIMES[initialIndex].label);
  const [endLabel, setEndLabel] = useState(TIMES[initialIndex + 2]?.label || TIMES[initialIndex].label);

  // Layout Widths
  const [segmentWidth, setSegmentWidth] = useState(0);

  // Animations
  const expandAnim = useSharedValue(0);
  const rangeAnim = useSharedValue(0);
  
  // Scroll Values
  const scrollYStart = useSharedValue(initialIndex * ITEM_HEIGHT);
  const scrollYEnd = useSharedValue((initialIndex + 2) * ITEM_HEIGHT);
  const scrollViewStartRef = useAnimatedRef<Animated.ScrollView>();
  const scrollViewEndRef = useAnimatedRef<Animated.ScrollView>();

  const bgPrimary = isDarkMode ? '#1C1C1E' : '#FFFFFF';
  const bgSecondary = isDarkMode ? '#2C2C2E' : '#F2F2F7';
  const textPrimary = isDarkMode ? '#FFFFFF' : '#000000';
  const textSecondary = isDarkMode ? '#8E8E93' : '#8E8E93';
  const accentColorStart = '#0A84FF'; // Blue
  const accentColorEnd = '#FF9F0A'; // Orange

  // Spring Configs
  const SPRING_CONFIG = {
    mass: 1,
    damping: 18,
    stiffness: 150,
  };

  // Handlers
  const toggleExpand = useCallback(() => {
    setIsExpanded((prev) => {
      const next = !prev;
      expandAnim.value = withSpring(next ? 1 : 0, SPRING_CONFIG);
      return next;
    });
  }, [expandAnim]);

  const toggleMode = useCallback((mode: 'single' | 'range') => {
    setSelectionMode(mode);
    rangeAnim.value = withSpring(mode === 'range' ? 1 : 0, SPRING_CONFIG);
  }, [rangeAnim]);

  // Scroll Handlers
  const onScrollStart = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollYStart.value = event.contentOffset.y;
    },
    onMomentumEnd: (event) => {
      const activeIdx = Math.round(event.contentOffset.y / ITEM_HEIGHT);
      if (TIMES[activeIdx]) {
        runOnJS(setStartLabel)(TIMES[activeIdx].label);
        if (onTimeChange) {
          runOnJS(onTimeChange)(TIMES[activeIdx].label, undefined);
        }
      }
    },
  });

  const onScrollEnd = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollYEnd.value = event.contentOffset.y;
    },
    onMomentumEnd: (event) => {
      const activeIdx = Math.round(event.contentOffset.y / ITEM_HEIGHT);
      if (TIMES[activeIdx]) {
        runOnJS(setEndLabel)(TIMES[activeIdx].label);
        if (onTimeChange) {
          runOnJS(onTimeChange)(startLabel, TIMES[activeIdx].label);
        }
      }
    },
  });

  // Effects
  useEffect(() => {
    setTimeout(() => {
      if (scrollViewStartRef.current) {
        (scrollViewStartRef.current as any).scrollTo({ y: initialIndex * ITEM_HEIGHT, animated: false });
      }
      if (scrollViewEndRef.current) {
        (scrollViewEndRef.current as any).scrollTo({ y: (initialIndex + 2) * ITEM_HEIGHT, animated: false });
      }
    }, 100);
  }, []);

  // Styles
  const containerStyle = useAnimatedStyle(() => {
    return {
      height: interpolate(expandAnim.value, [0, 1], [64, 380], Extrapolation.CLAMP),
      backgroundColor: bgPrimary,
    };
  });

  const expandedContentStyle = useAnimatedStyle(() => {
    return {
      opacity: expandAnim.value,
      pointerEvents: expandAnim.value > 0.5 ? 'auto' : 'none',
      transform: [{ translateY: interpolate(expandAnim.value, [0, 1], [-20, 0], Extrapolation.CLAMP) }],
    };
  });

  const indicatorStyle = useAnimatedStyle(() => {
    return {
      transform: [{ translateX: rangeAnim.value * (segmentWidth / 2) }],
    };
  });

  // Clock Hand Derived Values
  const activeIndexStart = useDerivedValue(() => scrollYStart.value / ITEM_HEIGHT);
  const activeIndexEnd = useDerivedValue(() => scrollYEnd.value / ITEM_HEIGHT);

  const getHandRotation = (continuousIndex: number, isHour: boolean) => {
    'worklet';
    const timeInMinutes = continuousIndex * 30;
    if (isHour) {
      const hour = (timeInMinutes / 60) % 12;
      return hour * 30;
    } else {
      const minute = timeInMinutes % 60;
      return minute * 6;
    }
  };

  const startHourHandStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${getHandRotation(activeIndexStart.value, true)}deg` }] }));
  const startMinuteHandStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${getHandRotation(activeIndexStart.value, false)}deg` }] }));
  const endHourHandStyle = useAnimatedStyle(() => ({
    opacity: rangeAnim.value,
    transform: [{ rotate: `${getHandRotation(activeIndexEnd.value, true)}deg` }]
  }));
  const endMinuteHandStyle = useAnimatedStyle(() => ({
    opacity: rangeAnim.value,
    transform: [{ rotate: `${getHandRotation(activeIndexEnd.value, false)}deg` }]
  }));

  const listContainerStyle = useAnimatedStyle(() => {
    return {
      width: interpolate(rangeAnim.value, [0, 1], [130, 200]),
    };
  });

  const clockScaleStyle = useAnimatedStyle(() => {
    return {
      transform: [{ scale: interpolate(rangeAnim.value, [0, 1], [1, 0.85]) }],
    };
  });

  const endListStyle = useAnimatedStyle(() => {
    return {
      opacity: rangeAnim.value,
      width: interpolate(rangeAnim.value, [0, 1], [0, 90]),
      overflow: 'hidden',
    };
  });

  const startListStyle = useAnimatedStyle(() => {
    return {
      width: interpolate(rangeAnim.value, [0, 1], [130, 90]),
    };
  });

  return (
    <Animated.View style={[styles.container, containerStyle]}>
      {/* HEADER */}
      <TouchableOpacity activeOpacity={0.7} onPress={toggleExpand} style={styles.header}>
        <View style={styles.headerLeft}>
          <ClockIcon color={textSecondary} size={20} />
          <Text style={[styles.headerTitle, { color: textSecondary }]}>Time</Text>
        </View>
        <Text style={[styles.headerValue, { color: textPrimary }]}>
          {selectionMode === 'single' ? startLabel : `${startLabel} - ${endLabel}`}
        </Text>
      </TouchableOpacity>

      {/* EXPANDED CONTENT */}
      <Animated.View style={[styles.expandedContent, expandedContentStyle]}>
        
        {/* SEGMENTED CONTROL */}
        <View 
          style={[styles.segmentedControl, { backgroundColor: bgSecondary }]}
          onLayout={(e: any) => setSegmentWidth(e.nativeEvent.layout.width)}
        >
          <Animated.View style={[styles.segmentIndicator, { backgroundColor: bgPrimary }, indicatorStyle]} />
          
          <Pressable style={styles.segment} onPress={() => toggleMode('single')}>
            {selectionMode === 'single' && (
              <View style={[styles.checkCircle, { backgroundColor: accentColorStart }]}>
                <View style={styles.checkIcon} />
              </View>
            )}
            <Text style={[styles.segmentText, { color: selectionMode === 'single' ? textPrimary : textSecondary }]}>Single time</Text>
          </Pressable>
          
          <Pressable style={styles.segment} onPress={() => toggleMode('range')}>
            {selectionMode === 'range' && (
              <View style={[styles.checkCircle, { backgroundColor: accentColorEnd }]}>
                <View style={styles.checkIcon} />
              </View>
            )}
            <Text style={[styles.segmentText, { color: selectionMode === 'range' ? textPrimary : textSecondary }]}>Time range</Text>
          </Pressable>
        </View>

        <View style={styles.mainRow}>
          
          {/* ANALOG CLOCK */}
          <View style={styles.clockContainer}>
            <Animated.View style={[styles.clockFace, { backgroundColor: bgSecondary }, clockScaleStyle]}>
              {Array.from({ length: 12 }).map((_, i) => (
                <View key={i} style={[styles.clockTickWrapper, { transform: [{ rotate: `${i * 30}deg` }] }]}>
                  <View style={[styles.clockTick, { backgroundColor: textSecondary }]} />
                </View>
              ))}

              {/* Start Hands */}
              <Animated.View style={[styles.handWrapper, startHourHandStyle]}>
                <View style={[styles.hourHand, { backgroundColor: textPrimary }]} />
              </Animated.View>
              <Animated.View style={[styles.handWrapper, startMinuteHandStyle]}>
                <View style={[styles.minuteHand, { backgroundColor: accentColorStart }]} />
                <View style={[styles.minuteDot, { backgroundColor: accentColorStart }]} />
              </Animated.View>

              {/* End Hands */}
              <Animated.View style={[styles.handWrapper, endHourHandStyle]}>
                <View style={[styles.hourHand, { backgroundColor: textSecondary, opacity: 0.6, width: 3.5, height: 32, top: 43 }]} />
              </Animated.View>
              <Animated.View style={[styles.handWrapper, endMinuteHandStyle]}>
                <View style={[styles.minuteHand, { backgroundColor: accentColorEnd, width: 2.5, height: 46, top: 29 }]} />
                <View style={[styles.minuteDot, { backgroundColor: accentColorEnd, width: 6, height: 6, top: 26 }]} />
              </Animated.View>

              <View style={[styles.centerDot, { backgroundColor: textPrimary }]} />
            </Animated.View>
          </View>

          {/* TIME LISTS */}
          <Animated.View style={[styles.listContainer, listContainerStyle]}>
            <View style={styles.activeHighlight} />
            
            {/* Start List */}
            <Animated.View style={startListStyle}>
              <Animated.ScrollView
                ref={scrollViewStartRef}
                onScroll={onScrollStart}
                scrollEventThrottle={16}
                showsVerticalScrollIndicator={false}
                snapToInterval={ITEM_HEIGHT}
                decelerationRate="fast"
                disableIntervalMomentum={true}
                contentContainerStyle={{ paddingTop: ITEM_HEIGHT * 2, paddingBottom: ITEM_HEIGHT * 2 }}
              >
                {TIMES.map((item, i) => (
                  <TimeItem 
                    key={`start-${item.id}`} 
                    item={item} 
                    index={i} 
                    activeIndexContinuous={activeIndexStart} 
                    textPrimary={textPrimary} 
                  />
                ))}
              </Animated.ScrollView>
            </Animated.View>

            {/* End List */}
            <Animated.View style={endListStyle}>
              <Animated.ScrollView
                ref={scrollViewEndRef}
                onScroll={onScrollEnd}
                scrollEventThrottle={16}
                showsVerticalScrollIndicator={false}
                snapToInterval={ITEM_HEIGHT}
                decelerationRate="fast"
                disableIntervalMomentum={true}
                contentContainerStyle={{ paddingTop: ITEM_HEIGHT * 2, paddingBottom: ITEM_HEIGHT * 2 }}
              >
                {TIMES.map((item, i) => (
                  <TimeItem 
                    key={`end-${item.id}`} 
                    item={item} 
                    index={i} 
                    activeIndexContinuous={activeIndexEnd} 
                    textPrimary={textPrimary} 
                  />
                ))}
              </Animated.ScrollView>
            </Animated.View>
          </Animated.View>

        </View>
      </Animated.View>
    </Animated.View>
  );
}

// ─── STYLES ─────────────────────────────────────────────────────────────────

const styles = StyleSheet.create({
  container: {
    borderRadius: 24,
    overflow: 'hidden',
    width: '100%',
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 10 }, shadowOpacity: 0.1, shadowRadius: 20 },
      android: { elevation: 10 },
    }),
  },
  header: {
    height: 64,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    paddingHorizontal: 20,
    zIndex: 10,
  },
  headerLeft: {
    flexDirection: 'row',
    alignItems: 'center',
  },
  headerTitle: {
    fontSize: 17,
    fontWeight: '500',
    marginLeft: 10,
  },
  headerValue: {
    fontSize: 17,
    fontWeight: '600',
  },
  expandedContent: {
    flex: 1,
    paddingHorizontal: 20,
    paddingBottom: 20,
  },
  segmentedControl: {
    flexDirection: 'row',
    height: 36,
    borderRadius: 18,
    padding: 2,
    marginBottom: 24,
    position: 'relative',
  },
  segmentIndicator: {
    position: 'absolute',
    top: 2,
    bottom: 2,
    left: 2,
    width: '50%',
    borderRadius: 16,
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4 },
      android: { elevation: 2 },
    }),
  },
  segment: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    zIndex: 2,
  },
  checkCircle: {
    width: 16,
    height: 16,
    borderRadius: 8,
    marginRight: 6,
    alignItems: 'center',
    justifyContent: 'center',
    transform: [{ translateY: 0.5 }],
  },
  checkIcon: {
    width: 8,
    height: 5,
    borderBottomWidth: 1.5,
    borderLeftWidth: 1.5,
    borderColor: '#FFF',
    transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 0.5 }],
  },
  segmentText: {
    fontSize: 13,
    fontWeight: '600',
    lineHeight: 16,
  },
  mainRow: {
    flexDirection: 'row',
    flex: 1,
  },
  clockContainer: {
    flex: 1.2,
    alignItems: 'center',
    justifyContent: 'center',
  },
  clockFace: {
    width: 150,
    height: 150,
    borderRadius: 75,
    alignItems: 'center',
    justifyContent: 'center',
    position: 'relative',
  },
  clockTickWrapper: {
    position: 'absolute',
    width: 150,
    height: 150,
    alignItems: 'center',
    paddingTop: 8,
  },
  clockTick: {
    width: 2,
    height: 6,
    borderRadius: 1,
    opacity: 0.3,
  },
  handWrapper: {
    position: 'absolute',
    width: 150,
    height: 150,
    alignItems: 'center',
    justifyContent: 'center',
  },
  hourHand: {
    width: 4.5,
    height: 38,
    borderRadius: 2.5,
    position: 'absolute',
    top: 37,
  },
  minuteHand: {
    width: 3,
    height: 54,
    borderRadius: 1.5,
    position: 'absolute',
    top: 21,
  },
  minuteDot: {
    width: 8,
    height: 8,
    borderRadius: 4,
    position: 'absolute',
    top: 17,
  },
  centerDot: {
    width: 8,
    height: 8,
    borderRadius: 4,
    position: 'absolute',
  },
  listContainer: {
    flexDirection: 'row',
    height: 220, 
    position: 'relative',
  },
  activeHighlight: {
    position: 'absolute',
    top: ITEM_HEIGHT * 2,
    left: 8,
    right: 8,
    height: ITEM_HEIGHT,
    backgroundColor: 'rgba(150, 150, 150, 0.12)',
    borderRadius: 12,
  },
  timeItem: {
    height: ITEM_HEIGHT,
    justifyContent: 'center',
    alignItems: 'center',
    width: '100%',
  },
  timeText: {
    fontSize: 16,
    fontWeight: '600',
    letterSpacing: 0.3,
  },
});

Usage Examples

Basic Usage

Render the time picker in your view. It automatically handles its own expanded/collapsed states.

Example Code
import TimePicker from '@/components/TimePicker';

export default function App() {
  return (
    <View style={{ flex: 1, padding: 20, justifyContent: 'center' }}>
      <TimePicker 
        theme="dark"
        onTimeChange={(time) => console.log('Time:', time)}
        onRangeChange={(start, end) => console.log('Range:', start, end)}
      />
    </View>
  );
}

Props

PropTypeDefaultDescription
theme
'light' | 'dark''light'The color theme for the time picker.
initialIndex
number28The starting index for the time list (0-47).
onTimeChange
(time: string) => void-Callback fired when a single time is selected.
onRangeChange
(start: string, end: string) => void-Callback fired when a time range is selected.
containerStyle
ViewStyle-Optional custom styles for the main container.

Features

  • Custom built, scroll-driven interactive analog clock
  • Supports both Single Time and Time Range selection modes
  • Fluid spring animations using React Native Reanimated
  • Fully customizable colors and theme support
  • Zero-dependency built-in SVG-style icons

Dependencies

Required

react-native-reanimated