Skip to content
pixelfor Expo
Ctrl K

Input

Duration Picker

A joined duration control separates into editable fields

Try it on your phone

Duration Picker

Scan to open Duration Picker in Expo Go

Install Expo Go first, then scan this QR to run the native component.

GET EXPO GO ONGoogle Play

This demo uses Expo SDK 54. If your installed Expo Go supports a different SDK, get the compatible build. iOS needs a build supporting this SDK.

Expo code

Original web component

Rare UI

Our Expo component

React Native Web

Component source

Make it yours

One file to copy into your app.

duration-picker.tsx

import React, { useEffect, useRef, useState } from "react"
import { Platform, Pressable, Text, TextInput, View } from "react-native"
import Animated, {
  useAnimatedProps,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import Svg, { Path } from "react-native-svg"
const APath = Animated.createAnimatedComponent(Path)
export type DurationValue = { hours: number; minutes: number }
export interface DurationPickerProps {
  value?: DurationValue
  defaultValue?: DurationValue
  onChange?: (value: DurationValue) => void
  onConfirm?: (value: DurationValue) => void
  onEditingChange?: (editing: boolean) => void
  defaultEditing?: boolean
  maxHours?: number
  maxMinutes?: number
  hoursLabel?: string
  minutesLabel?: string
  disabled?: boolean
  backgroundColor?: string
  textColor?: string
  mutedColor?: string
  radius?: number
  testID?: string
}
const pen = [
  [3, 21],
  [3.8, 16.3],
  [5, 14],
  [15.5, 3.6],
  [18.3, 3.6],
  [20.4, 5.7],
  [20.4, 8.5],
  [9.8, 19.1],
  [7.7, 20.2],
]
const tick = [
  [1.6, 12.9],
  [3.1, 11.6],
  [6.1, 15.2],
  [8, 17.5],
  [20.9, 3.6],
  [22.4, 4.9],
  [17.6, 10.1],
  [8, 20.5],
  [4.8, 16.7],
]
export function DurationPicker({
  value,
  defaultValue = { hours: 0, minutes: 0 },
  onChange,
  onConfirm,
  onEditingChange,
  defaultEditing = false,
  maxHours = 24,
  maxMinutes = 60,
  hoursLabel = "Hr.",
  minutesLabel = "Min.",
  disabled = false,
  backgroundColor = "#262626",
  textColor = "#ffffff",
  mutedColor = "rgba(134,133,147,.7)",
  radius = 12,
  testID,
}: DurationPickerProps) {
  const [local, setLocal] = useState(defaultValue),
    [editing, setEditing] = useState(defaultEditing),
    [hours, setHours] = useState((value ?? defaultValue).hours ? String((value ?? defaultValue).hours) : ""),
    [minutes, setMinutes] = useState((value ?? defaultValue).minutes ? String((value ?? defaultValue).minutes) : "")
  const reduced = useReducedMotion(),
    progress = useSharedValue(Number(defaultEditing)),
    shake = useSharedValue(0),
    input = useRef<TextInput>(null)
  useEffect(() => {
    if (value) {
      setHours(value.hours ? String(value.hours) : "")
      setMinutes(value.minutes ? String(value.minutes) : "")
    }
  }, [value?.hours, value?.minutes])
  useEffect(() => {
    progress.value = reduced
      ? Number(editing)
      : withSpring(Number(editing), { stiffness: 200, damping: 28 })
    if (editing) input.current?.focus()
  }, [editing, reduced, progress])
  const group = useAnimatedStyle(() => ({
    gap: 8 * Math.max(0, Math.min(1, progress.value)),
    transform: [{ translateX: shake.value }],
  }))
  const left = useAnimatedStyle(() => ({
    borderTopRightRadius: radius * progress.value,
    borderBottomRightRadius: radius * progress.value,
    paddingLeft: 8,
    paddingRight: 3 + 9 * progress.value,
  }))
  const middle = useAnimatedStyle(() => ({
    borderRadius: radius * progress.value,
    paddingLeft: 9 * progress.value,
    paddingRight: 3 + 9 * progress.value,
    marginLeft: progress.value - 1,
  }))
  const right = useAnimatedStyle(() => ({
    borderTopLeftRadius: radius * progress.value,
    borderBottomLeftRadius: radius * progress.value,
    marginLeft: progress.value - 1,
  }))
  const icon = useAnimatedProps(() => ({
    d:
      pen
        .map(
          (point, i) =>
            `${i ? "L" : "M"}${point[0] + (tick[i][0] - point[0]) * progress.value},${point[1] + (tick[i][1] - point[1]) * progress.value}`
        )
        .join(" ") + "Z",
  }))
  function update(field: "hours" | "minutes", text: string) {
    const max = field === "hours" ? maxHours : maxMinutes
    if (!/^\d*$/.test(text) || Number(text) > max) {
      if (!reduced)
        shake.value = withSequence(
          withTiming(-3, { duration: 50 }),
          withTiming(3, { duration: 60 }),
          withTiming(0, { duration: 80 })
        )
      return
    }
    if (field === "hours") setHours(text)
    else setMinutes(text)
    const next = {
      hours: Number(field === "hours" ? text : hours),
      minutes: Number(field === "minutes" ? text : minutes),
    }
    if (value === undefined) setLocal(next)
    onChange?.(next)
  }
  const fieldStyle = {
    color: textColor,
    fontSize: 16,
    fontWeight: "600" as const,
    padding: 0,
    width: editing ? 44 : 22,
    fontFamily: Platform.OS === "web" ? "Arial" : undefined,
    ...(Platform.OS === "web" ? { outlineWidth: 0 } : {}),
    textAlign: "center" as const,
  }
  return (
    <Animated.View
      testID={testID}
      style={[
        { flexDirection: "row", justifyContent: "center", alignSelf: "center" },
        group,
      ]}
    >
      <Animated.View
        style={[
          {
            height: 48,
            backgroundColor,
            flexDirection: "row",
            alignItems: "center",
            gap: 4,
            borderTopLeftRadius: radius,
            borderBottomLeftRadius: radius,
            borderCurve: "continuous",
          },
          left,
        ]}
      >
        <TextInput
          ref={input}
          accessibilityLabel="Hours"
          editable={editing && !disabled}
          keyboardType="number-pad"
          selectTextOnFocus
          value={hours}
          placeholder={editing ? "" : "0"}
          placeholderTextColor={textColor}
          onChangeText={(text) => update("hours", text)}
          style={fieldStyle}
        />
        <Text style={{ color: mutedColor, fontSize: 16, fontWeight: "600", fontFamily: fieldStyle.fontFamily }}>{hoursLabel}</Text>
      </Animated.View>
      <Animated.View
        style={[
          {
            height: 48,
            backgroundColor,
            flexDirection: "row",
            alignItems: "center",
            gap: 4,
            borderCurve: "continuous",
          },
          middle,
        ]}
      >
        <TextInput
          accessibilityLabel="Minutes"
          editable={editing && !disabled}
          keyboardType="number-pad"
          selectTextOnFocus
          value={minutes}
          placeholder={editing ? "" : "0"}
          placeholderTextColor={textColor}
          onChangeText={(text) => update("minutes", text)}
          style={fieldStyle}
        />
        <Text style={{ color: mutedColor, fontSize: 16, fontWeight: "500", fontFamily: fieldStyle.fontFamily }}>{minutesLabel}</Text>
      </Animated.View>
      <Animated.View
        style={[
          {
            backgroundColor,
            borderTopRightRadius: radius,
            borderBottomRightRadius: radius,
            borderCurve: "continuous",
          },
          right,
        ]}
      >
        <Pressable
          disabled={disabled}
          accessibilityRole="button"
          accessibilityLabel={editing ? "Confirm duration" : "Edit duration"}
          onPress={() => {
            const next = !editing
            setEditing(next)
            onEditingChange?.(next)
            if (!next)
              onConfirm?.({ hours: Number(hours), minutes: Number(minutes) })
          }}
          style={{
            width: 48,
            height: 48,
            alignItems: "center",
            justifyContent: "center",
          }}
        >
          <Svg width={18} height={18} viewBox="0 0 24 24">
            <APath animatedProps={icon} fill={mutedColor} />
          </Svg>
        </Pressable>
      </Animated.View>
    </Animated.View>
  )
}
export default DurationPicker

Give the gestures and motion a run on a real Android phone.

Back to Rare UI