Input
Duration Picker
A joined duration control separates into editable fields
Expo code
Original web component
Rare UIOur Expo component
React Native WebComponent 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.