rapidUI

Time Picker

Time picker: a 24-hour field with hour/minute segments in the style of Input, standalone segment inputs, and a visual popover variant.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/time-picker.json

Installs to @/components/ui/time-picker.

Manual installation

Install the dependencies:

pnpm add lucide-react@^1.48.0

Add the components it builds on: button, popover, scroll-area.

Copy the source into your project:

components/ui/time-picker/index.ts
export * from './time-picker'
export * from './visual-time-picker'
components/ui/time-picker/time-picker-utils.ts
/**
 * regular expression to check for valid hour format (01-23)
 */
export function isValidHour(value: string) {
	return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value)
}

/**
 * regular expression to check for valid 12 hour format (01-12)
 */
export function isValid12Hour(value: string) {
	return /^(0[1-9]|1[0-2])$/.test(value)
}

/**
 * regular expression to check for valid minute format (00-59)
 */
export function isValidMinuteOrSecond(value: string) {
	return /^[0-5][0-9]$/.test(value)
}

type GetValidNumberConfig = { max: number; min?: number; loop?: boolean }

export function getValidNumber(value: string, { max, min = 0, loop = false }: GetValidNumberConfig) {
	let numericValue = Number.parseInt(value, 10)

	if (!Number.isNaN(numericValue)) {
		if (!loop) {
			if (numericValue > max) numericValue = max
			if (numericValue < min) numericValue = min
		} else {
			if (numericValue > max) numericValue = min
			if (numericValue < min) numericValue = max
		}
		return numericValue.toString().padStart(2, '0')
	}

	return '00'
}

export function getValidHour(value: string) {
	if (isValidHour(value)) return value
	return getValidNumber(value, { max: 23 })
}

export function getValid12Hour(value: string) {
	if (isValid12Hour(value)) return value
	return getValidNumber(value, { min: 1, max: 12 })
}

export function getValidMinuteOrSecond(value: string) {
	if (isValidMinuteOrSecond(value)) return value
	return getValidNumber(value, { max: 59 })
}

type GetValidArrowNumberConfig = {
	min: number
	max: number
	step: number
}

export function getValidArrowNumber(value: string, { min, max, step }: GetValidArrowNumberConfig) {
	let numericValue = Number.parseInt(value, 10)
	if (!Number.isNaN(numericValue)) {
		numericValue += step
		return getValidNumber(String(numericValue), { min, max, loop: true })
	}
	return '00'
}

export function getValidArrowHour(value: string, step: number) {
	return getValidArrowNumber(value, { min: 0, max: 23, step })
}

export function getValidArrow12Hour(value: string, step: number) {
	return getValidArrowNumber(value, { min: 1, max: 12, step })
}

export function getValidArrowMinuteOrSecond(value: string, step: number) {
	return getValidArrowNumber(value, { min: 0, max: 59, step })
}

export function setMinutes(date: Date, value: string) {
	const minutes = getValidMinuteOrSecond(value)
	date.setMinutes(Number.parseInt(minutes, 10))
	return date
}

export function setSeconds(date: Date, value: string) {
	const seconds = getValidMinuteOrSecond(value)
	date.setSeconds(Number.parseInt(seconds, 10))
	return date
}

export function setHours(date: Date, value: string) {
	const hours = getValidHour(value)
	date.setHours(Number.parseInt(hours, 10))
	return date
}

export function set12Hours(date: Date, value: string, period: Period) {
	const hours = Number.parseInt(getValid12Hour(value), 10)
	const convertedHours = convert12HourTo24Hour(hours, period)
	date.setHours(convertedHours)
	return date
}

export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours'
export type Period = 'AM' | 'PM'

export function setDateByType(date: Date, value: string, type: TimePickerType, period?: Period) {
	switch (type) {
		case 'minutes':
			return setMinutes(date, value)
		case 'seconds':
			return setSeconds(date, value)
		case 'hours':
			return setHours(date, value)
		case '12hours': {
			if (!period) return date
			return set12Hours(date, value, period)
		}
		default:
			return date
	}
}

export function getDateByType(date: Date, type: TimePickerType) {
	switch (type) {
		case 'minutes':
			return getValidMinuteOrSecond(String(date.getMinutes()))
		case 'seconds':
			return getValidMinuteOrSecond(String(date.getSeconds()))
		case 'hours':
			return getValidHour(String(date.getHours()))
		case '12hours': {
			const hours = display12HourValue(date.getHours())
			return getValid12Hour(String(hours))
		}
		default:
			return '00'
	}
}

export function getArrowByType(value: string, step: number, type: TimePickerType) {
	switch (type) {
		case 'minutes':
			return getValidArrowMinuteOrSecond(value, step)
		case 'seconds':
			return getValidArrowMinuteOrSecond(value, step)
		case 'hours':
			return getValidArrowHour(value, step)
		case '12hours':
			return getValidArrow12Hour(value, step)
		default:
			return '00'
	}
}

/**
 * handles value change of 12-hour input
 * 12:00 PM is 12:00
 * 12:00 AM is 00:00
 */
export function convert12HourTo24Hour(hour: number, period: Period) {
	if (period === 'PM') {
		if (hour <= 11) {
			return hour + 12
		}
		return hour
	}
	if (period === 'AM') {
		if (hour === 12) return 0
		return hour
	}
	return hour
}

/**
 * time is stored in the 24-hour form,
 * but needs to be displayed to the user
 * in its 12-hour representation
 */
export function display12HourValue(hours: number) {
	if (hours === 0 || hours === 12) return '12'
	if (hours >= 22) return `${hours - 12}`
	if (hours % 12 > 9) return `${hours}`
	return `0${hours % 12}`
}
components/ui/time-picker/time-picker.tsx
'use client'

import { ClockIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'
import { getArrowByType, getDateByType, type Period, setDateByType, type TimePickerType } from './time-picker-utils'

export interface TimePickerInputProps extends Omit<React.ComponentProps<'input'>, 'type'> {
	picker: TimePickerType
	date: Date | undefined
	setDate: (date: Date | undefined) => void
	period?: Period
	onRightFocus?: () => void
	onLeftFocus?: () => void
	/** `default` is a standalone field; `segment` is borderless, for use inside `TimePicker` or your own field */
	variant?: 'default' | 'segment'
}

const inputVariants = {
	default:
		'h-10 w-12 rounded-lg border border-input bg-background px-1 text-sm transition-colors hover:border-primary/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:pointer-events-none disabled:opacity-50 read-only:cursor-default read-only:border-dashed read-only:bg-muted/50 read-only:hover:border-input',
	segment: 'h-6 w-7 rounded-sm bg-transparent p-0 text-sm disabled:pointer-events-none read-only:cursor-default',
}

/** One time segment (hours, minutes, seconds or 12-hour hours). Type digits or use the arrow keys. */
function TimePickerInput({
	className,
	variant = 'default',
	value,
	date = new Date(new Date().setHours(0, 0, 0, 0)),
	setDate,
	onChange,
	onKeyDown,
	picker,
	period,
	onLeftFocus,
	onRightFocus,
	readOnly,
	...props
}: Readonly<TimePickerInputProps>) {
	const [flag, setFlag] = React.useState(false)
	const [prevIntKey, setPrevIntKey] = React.useState('0')

	// Allow the second digit within two seconds, otherwise start again with the first digit
	React.useEffect(() => {
		if (!flag) return
		const timer = setTimeout(() => setFlag(false), 2000)
		return () => clearTimeout(timer)
	}, [flag])

	const calculatedValue = React.useMemo(() => getDateByType(date, picker), [date, picker])

	const calculateNewValue = (key: string) => {
		// In 12-hour mode a leading 0 makes the next digit 1–9; a following digit gives 10–12
		if (picker === '12hours' && flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return `0${key}`
		return !flag ? `0${key}` : `${calculatedValue.slice(1, 2)}${key}`
	}

	const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
		if (event.key === 'Tab') return
		event.preventDefault()
		if (event.key === 'ArrowRight') onRightFocus?.()
		if (event.key === 'ArrowLeft') onLeftFocus?.()
		if (readOnly) return
		if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
			const newValue = getArrowByType(calculatedValue, event.key === 'ArrowUp' ? 1 : -1, picker)
			if (flag) setFlag(false)
			setDate(setDateByType(new Date(date), newValue, picker, period))
		}
		if (event.key >= '0' && event.key <= '9') {
			if (picker === '12hours') setPrevIntKey(event.key)
			const newValue = calculateNewValue(event.key)
			if (flag) onRightFocus?.()
			setFlag((prev) => !prev)
			setDate(setDateByType(new Date(date), newValue, picker, period))
		}
	}

	return (
		<input
			data-slot="time-picker-input"
			type="text"
			inputMode="numeric"
			autoComplete="off"
			spellCheck={false}
			readOnly={readOnly}
			className={cn(
				'text-center tabular-nums text-foreground caret-transparent outline-none selection:bg-transparent focus:bg-accent focus:text-accent-foreground',
				inputVariants[variant],
				className,
			)}
			value={value ?? calculatedValue}
			onChange={(event) => {
				event.preventDefault()
				onChange?.(event)
			}}
			onKeyDown={(event) => {
				onKeyDown?.(event)
				handleKeyDown(event)
			}}
			{...props}
		/>
	)
}

export interface TimePickerProps {
	/** Controlled value. The date part is kept; only the time changes. */
	value?: Date
	defaultValue?: Date
	onValueChange?: (date: Date | undefined) => void
	/** Show a seconds segment */
	withSeconds?: boolean
	disabled?: boolean
	readOnly?: boolean
	/** id of the hours segment, so a `<Label htmlFor>` focuses the field */
	id?: string
	className?: string
	/** Screen reader names for the segments */
	labels?: { hours?: string; minutes?: string; seconds?: string }
	'aria-label'?: string
	'aria-labelledby'?: string
	'aria-describedby'?: string
	'aria-invalid'?: React.AriaAttributes['aria-invalid']
}

/** A 24-hour time field in the style of `Input`: `HH : MM` (optionally `: SS`) segments in one box. */
function TimePicker({
	value,
	defaultValue,
	onValueChange,
	withSeconds = false,
	disabled,
	readOnly,
	id,
	className,
	labels,
	'aria-invalid': ariaInvalid,
	...groupProps
}: Readonly<TimePickerProps>) {
	const [uncontrolled, setUncontrolled] = React.useState<Date | undefined>(defaultValue)
	const date = value ?? uncontrolled
	const setDate = (next: Date | undefined) => {
		if (value === undefined) setUncontrolled(next)
		onValueChange?.(next)
	}

	const hourRef = React.useRef<HTMLInputElement>(null)
	const minuteRef = React.useRef<HTMLInputElement>(null)
	const secondRef = React.useRef<HTMLInputElement>(null)

	const segment = {
		variant: 'segment' as const,
		date,
		setDate,
		disabled,
		readOnly,
		'aria-invalid': ariaInvalid,
	}

	return (
		// biome-ignore lint/a11y/useSemanticElements: a <fieldset> can't centre its flex content vertically in Chrome
		<div
			role="group"
			data-slot="time-picker"
			data-disabled={disabled || undefined}
			data-read-only={readOnly || undefined}
			data-invalid={ariaInvalid && ariaInvalid !== 'false' ? '' : undefined}
			{...groupProps}
			className={cn(
				'inline-flex h-10 w-fit min-w-0 items-center gap-0.5 rounded-lg border border-input bg-background pr-1.5 pl-2.5 text-sm transition-colors hover:border-primary/90 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring data-invalid:border-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 data-read-only:border-dashed data-read-only:bg-muted/50 data-read-only:hover:border-input',
				className,
			)}
		>
			<ClockIcon aria-hidden="true" className="mr-1 size-4 shrink-0 text-muted-foreground" />
			<TimePickerInput
				{...segment}
				ref={hourRef}
				id={id}
				picker="hours"
				aria-label={labels?.hours ?? 'Hours'}
				onRightFocus={() => minuteRef.current?.focus()}
			/>
			<span aria-hidden="true" className="text-muted-foreground">
				:
			</span>
			<TimePickerInput
				{...segment}
				ref={minuteRef}
				picker="minutes"
				aria-label={labels?.minutes ?? 'Minutes'}
				onLeftFocus={() => hourRef.current?.focus()}
				onRightFocus={() => secondRef.current?.focus()}
			/>
			{withSeconds ? (
				<>
					<span aria-hidden="true" className="text-muted-foreground">
						:
					</span>
					<TimePickerInput
						{...segment}
						ref={secondRef}
						picker="seconds"
						aria-label={labels?.seconds ?? 'Seconds'}
						onLeftFocus={() => minuteRef.current?.focus()}
					/>
				</>
			) : null}
		</div>
	)
}

export { TimePicker, TimePickerInput }
components/ui/time-picker/visual-time-picker.tsx
'use client'

import { ClockIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'

export interface VisualTimePickerProps {
	date?: Date
	setDate: (date: Date | undefined) => void
	className?: string
	disabled?: boolean
	placeholder?: string
	readOnly?: boolean
	labels?: {
		hours: string
		minutes: string
	}
	/** marks the trigger as invalid (destructive border + ring) */
	'aria-invalid'?: React.AriaAttributes['aria-invalid']
	/** id for the trigger button (e.g. to pair with a `<Label htmlFor>`) */
	id?: string
	/** accessible name when there is no visible `<Label htmlFor={id}>` */
	'aria-label'?: string
	/**
	 * Ids of the elements that name the picker, usually the field's label. The button adds
	 * itself, so the name ends with the chosen value (or the placeholder): "Date of birth,
	 * required, 15 Oct 2026". Takes precedence over `aria-label`.
	 */
	'aria-labelledby'?: string
	/** Ids of the help text and error message, passed on to the button */
	'aria-describedby'?: string
}

export function VisualTimePicker({
	date,
	setDate,
	className,
	disabled,
	placeholder,
	readOnly,
	labels,
	id,
	'aria-invalid': ariaInvalid,
	'aria-label': ariaLabel,
	'aria-labelledby': ariaLabelledby,
	'aria-describedby': ariaDescribedby,
}: Readonly<VisualTimePickerProps>) {
	const [isOpen, setIsOpen] = React.useState(false)
	const generatedId = React.useId()
	const buttonId = id ?? generatedId

	// Create arrays for hours (0-23) and minutes (every 5th minute)
	const hours = Array.from({ length: 24 }, (_, i) => i)
	const minutes = Array.from({ length: 12 }, (_, i) => i * 5)

	const handleTimeChange = (type: 'hour' | 'minute', value: number) => {
		// If no date is set, start from now but reset the time first for a clean start
		const newDate = date ? new Date(date) : new Date()

		if (!date) {
			newDate.setHours(0, 0, 0, 0)
		}

		if (type === 'hour') {
			newDate.setHours(value)
		} else {
			newDate.setMinutes(value)
		}

		setDate(newDate)
	}

	return (
		<Popover open={isOpen && !readOnly} onOpenChange={setIsOpen}>
			<PopoverTrigger
				disabled={disabled}
				render={
					<Button
						variant="outline"
						data-read-only={readOnly || undefined}
						data-empty={!date || undefined}
						aria-invalid={ariaInvalid}
						id={buttonId}
						aria-label={ariaLabelledby ? undefined : ariaLabel}
						aria-labelledby={ariaLabelledby ? `${ariaLabelledby} ${buttonId}` : undefined}
						aria-describedby={ariaDescribedby}
						className={cn(
							'w-full justify-between font-normal hover:border-primary hover:bg-transparent hover:text-foreground data-empty:text-muted-foreground data-read-only:pointer-events-none data-read-only:cursor-default data-read-only:border-dashed data-read-only:border-input data-read-only:bg-muted/50 data-read-only:text-foreground data-read-only:hover:border-input data-read-only:hover:bg-muted/50',
							className,
						)}
					/>
				}
			>
				{date ? (
					<span>
						{date.getHours().toString().padStart(2, '0')}:{date.getMinutes().toString().padStart(2, '0')}
					</span>
				) : (
					<span>{placeholder}</span>
				)}
				<ClockIcon data-icon="inline-end" className="text-muted-foreground" />
			</PopoverTrigger>
			<PopoverContent
				className="max-h-(--available-height) w-auto overflow-y-auto p-0"
				align="start"
				// Named after the field, so the list is not announced as just "dialog"
				aria-labelledby={ariaLabelledby}
				aria-label={ariaLabelledby ? undefined : ariaLabel}
			>
				<div className="flex h-[300px] divide-x">
					{/* Hours */}
					<ScrollArea className="h-full w-20">
						<div className="flex flex-col p-2">
							<div className="mb-2 text-xs font-medium text-center text-muted-foreground">{labels?.hours}</div>
							{hours.map((hour) => (
								<Button
									key={hour}
									variant={date && date.getHours() === hour ? 'default' : 'ghost'}
									size="sm"
									className="my-0.5 shrink-0 justify-center"
									onClick={() => handleTimeChange('hour', hour)}
								>
									{hour.toString().padStart(2, '0')}
								</Button>
							))}
						</div>
						<ScrollBar orientation="vertical" />
					</ScrollArea>

					{/* Minutes */}
					<ScrollArea className="h-full w-20">
						<div className="flex flex-col p-2">
							<div className="mb-2 text-xs font-medium text-center text-muted-foreground">{labels?.minutes}</div>
							{minutes.map((minute) => (
								<Button
									key={minute}
									variant={date && date.getMinutes() === minute ? 'default' : 'ghost'}
									size="sm"
									className="my-0.5 shrink-0 justify-center"
									onClick={() => handleTimeChange('minute', minute)}
								>
									{minute.toString().padStart(2, '0')}
								</Button>
							))}
						</div>
						<ScrollBar orientation="vertical" />
					</ScrollArea>
				</div>
			</PopoverContent>
		</Popover>
	)
}

Installs as a folder (components/ui/time-picker/) with three components and a set of helpers.

Features

  • A 24-hour HH : MM field in the style of Input
  • Optional seconds
  • Controlled or uncontrolled (value / defaultValue / onValueChange)
  • Keyboard entry (digits, ↑/↓, ←/→ between segments)
  • Disabled, read-only and invalid states
  • VisualTimePicker with a popover of scrollable hour and minute lists

Usage

import { TimePicker, TimePickerInput, VisualTimePicker } from "@/components/ui/time-picker"

const [date, setDate] = useState<Date | undefined>()
const minuteRef = useRef<HTMLInputElement>(null)

// Single field
<Label htmlFor="pickup">Pick-up time</Label>
<TimePicker id="pickup" value={date} onValueChange={setDate} />

// Keyboard fields
<TimePickerInput picker="hours" date={date} setDate={setDate} onRightFocus={() => minuteRef.current?.focus()} />
<TimePickerInput picker="minutes" date={date} setDate={setDate} ref={minuteRef} />

// Popover picker
<VisualTimePicker
  date={date}
  setDate={setDate}
  placeholder="Select time"
  labels={{ hours: 'Hours', minutes: 'Minutes' }}
/>

Examples

Default

A TimePicker in a Field, with a description linked through aria-describedby.

Time range

Two controlled pickers for booking a sports hall. When the end is before the start, the second field is marked with aria-invalid and the error is shown with FieldError.

With seconds

withSeconds adds a seconds segment.

States

Read only, disabled and invalid.

Keyboard inputs

Separate TimePickerInput fields with their own labels, chained with onLeftFocus / onRightFocus.

Visual picker

VisualTimePicker opens a popover with hour and minute lists (minutes in steps of five).

Visual picker — read only and disabled

Props

TimePicker

  • value - Controlled value. The date part is kept; only the time changes.
  • defaultValue - The initial value when uncontrolled
  • onValueChange - Event handler for when the time changes, (date: Date | undefined) => void
  • withSeconds - Show a seconds segment (default false)
  • disabled - Disables the field
  • readOnly - Makes the field read only
  • id - id of the hours segment, so a <Label htmlFor> focuses the field
  • className - Additional CSS classes
  • labels - Screen reader names for the segments, { hours?, minutes?, seconds? } (default Hours, Minutes, Seconds)
  • aria-label - Accessible name when there is no visible label
  • aria-labelledby - Id of the element that names the field
  • aria-describedby - Ids of the help text and error message
  • aria-invalid - Marks the field as invalid (destructive border and ring)

TimePickerInput

  • picker - Which unit the field edits: hours, 12hours, minutes or seconds
  • date - The Date the field reads from
  • setDate - Called with the new Date when the value changes, (date: Date | undefined) => void
  • period - AM or PM, used with picker="12hours"
  • onLeftFocus - Called on ←, to move focus to the previous field
  • onRightFocus - Called on → and after the second digit, to move focus to the next field
  • variant - default is a standalone field; segment is borderless, for use inside TimePicker or your own field (default default)
  • All input attributes except type, including className, disabled, readOnly and aria-*

VisualTimePicker

  • date - The selected time
  • setDate - Called with the new Date when an hour or minute is picked, (date: Date | undefined) => void
  • disabled - Disables the button
  • readOnly - Shows the time with a dashed border and does not open the popover
  • placeholder - Text shown on the button while no time is selected
  • labels - Headings above the hour and minute lists, { hours, minutes }
  • id - id of the button, e.g. to pair with a <Label htmlFor>
  • className - Additional CSS classes
  • aria-label - Accessible name when there is no visible label
  • aria-labelledby - Ids of the elements that name the picker, usually the field's label. The button adds itself, so the name ends with the chosen value (or the placeholder). Takes precedence over aria-label.
  • aria-describedby - Ids of the help text and error message, passed on to the button
  • aria-invalid - Marks the button as invalid (destructive border and ring)

Accessibility

  • The field is a role="group"
  • Pass id so a <Label htmlFor> focuses the hours segment, or aria-label / aria-labelledby when there is no visible label
  • Each segment gets its screen reader name from labels
  • aria-invalid and aria-describedby announce errors
  • Keyboard: type digits, ↑/↓ step the value, ←/→ move between segments, and after the second digit focus moves on to the next segment
  • Date Time Picker
  • Calendar
  • Popover