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.jsonInstalls to @/components/ui/time-picker.
Manual installation
Install the dependencies:
pnpm add lucide-react@^1.48.0Add the components it builds on: button, popover, scroll-area.
Copy the source into your project:
export * from './time-picker'
export * from './visual-time-picker'/**
* 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}`
}'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 }'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 : MMfield in the style of Input - Optional seconds
- Controlled or uncontrolled (
value/defaultValue/onValueChange) - Keyboard entry (digits, ↑/↓, ←/→ between segments)
- Disabled, read-only and invalid states
VisualTimePickerwith 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 uncontrolledonValueChange- Event handler for when the time changes,(date: Date | undefined) => voidwithSeconds- Show a seconds segment (defaultfalse)disabled- Disables the fieldreadOnly- Makes the field read onlyid- id of the hours segment, so a<Label htmlFor>focuses the fieldclassName- Additional CSS classeslabels- Screen reader names for the segments,{ hours?, minutes?, seconds? }(defaultHours,Minutes,Seconds)aria-label- Accessible name when there is no visible labelaria-labelledby- Id of the element that names the fieldaria-describedby- Ids of the help text and error messagearia-invalid- Marks the field as invalid (destructive border and ring)
TimePickerInput
picker- Which unit the field edits:hours,12hours,minutesorsecondsdate- TheDatethe field reads fromsetDate- Called with the newDatewhen the value changes,(date: Date | undefined) => voidperiod-AMorPM, used withpicker="12hours"onLeftFocus- Called on←, to move focus to the previous fieldonRightFocus- Called on→and after the second digit, to move focus to the next fieldvariant-defaultis a standalone field;segmentis borderless, for use insideTimePickeror your own field (defaultdefault)- All
inputattributes excepttype, includingclassName,disabled,readOnlyandaria-*
VisualTimePicker
date- The selected timesetDate- Called with the newDatewhen an hour or minute is picked,(date: Date | undefined) => voiddisabled- Disables the buttonreadOnly- Shows the time with a dashed border and does not open the popoverplaceholder- Text shown on the button while no time is selectedlabels- 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 classesaria-label- Accessible name when there is no visible labelaria-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 overaria-label.aria-describedby- Ids of the help text and error message, passed on to the buttonaria-invalid- Marks the button as invalid (destructive border and ring)
Accessibility
- The field is a
role="group" - Pass
idso a<Label htmlFor>focuses the hours segment, oraria-label/aria-labelledbywhen there is no visible label - Each segment gets its screen reader name from
labels aria-invalidandaria-describedbyannounce errors- Keyboard: type digits, ↑/↓ step the value, ←/→ move between segments, and after the second digit focus moves on to the next segment
Related Components
- Date Time Picker
- Calendar
- Popover