Time Picker
React date-timeStandalone time input — hours, minutes, optional seconds, and 12h/24h modes. Pairs with Date Picker for full datetime entry.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/time-picker.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/time-picker.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/time-picker.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/time-picker.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/time-picker
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
value Time value. HH:mm or HH:mm:ss depending on format. Empty = no selection. | string | — | optional |
onValueChange | (value: string) => void | — | optional |
use24Hour | boolean | — | optional |
use12Hours | boolean | — | optional |
minuteStep | number | — | optional |
hourStep | number | — | optional |
secondStep | number | — | optional |
minTime 24h HH:mm or HH:mm:ss. | string | — | optional |
maxTime 24h HH:mm or HH:mm:ss. | string | — | optional |
format | TimeFormat | — | optional |
disabledHours | () => number[] | — | optional |
disabledMinutes | (selectedHour: number) => number[] | — | optional |
disabledSeconds | (selectedHour: number, selectedMinute: number) => number[] | — | optional |
hideDisabledOptions | boolean | — | optional |
visible Auto-scroll active rows into view when this becomes true. | boolean | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
TimeParts interface TimeParts {
hour24: number
minute: number
second: number
} TimePreset interface TimePreset {
label: string
value: string
} Dependencies
Files (2)
-
components/ui/time-picker/time-picker.tsx 17.6 kB
'use client' import * as React from 'react' import { Clock, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { ScrollArea } from '@/components/ui/scroll-area' import { cn } from '@/lib/utils' export type TimeFormat = 'HH:mm' | 'HH:mm:ss' | 'hh:mm A' export interface TimeParts { hour24: number minute: number second: number } export interface TimePreset { label: string value: string } export type TimePickerSize = 'small' | 'middle' | 'large' export type TimePickerStatus = 'error' | 'warning' function parse(v: string): TimeParts | null { if (!v) return null const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(v.trim()) if (!m) return null const h = Number(m[1]) const min = Number(m[2]) const s = m[3] ? Number(m[3]) : 0 if ( Number.isNaN(h) || Number.isNaN(min) || Number.isNaN(s) || h < 0 || h > 23 || min < 0 || min > 59 || s < 0 || s > 59 ) return null return { hour24: h, minute: min, second: s } } function toMinutes(p: TimeParts) { return p.hour24 * 60 + p.minute + p.second / 60 } // ---- TimeColumns ---- export interface TimeColumnsProps { /** Time value. HH:mm or HH:mm:ss depending on format. Empty = no selection. */ value?: string onValueChange?: (value: string) => void use24Hour?: boolean use12Hours?: boolean minuteStep?: number hourStep?: number secondStep?: number /** 24h HH:mm or HH:mm:ss. */ minTime?: string /** 24h HH:mm or HH:mm:ss. */ maxTime?: string format?: TimeFormat disabledHours?: () => number[] disabledMinutes?: (selectedHour: number) => number[] disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[] hideDisabledOptions?: boolean /** Auto-scroll active rows into view when this becomes true. */ visible?: boolean } function TimeColumns({ value = '', onValueChange, use24Hour = false, use12Hours = false, minuteStep = 5, hourStep = 1, secondStep = 1, minTime, maxTime, format = 'HH:mm', disabledHours, disabledMinutes, disabledSeconds, hideDisabledOptions = false, visible = true, }: TimeColumnsProps) { const parts = parse(value) function format24(p: TimeParts) { if (format === 'HH:mm:ss') { return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}:${String(p.second).padStart(2, '0')}` } return `${String(p.hour24).padStart(2, '0')}:${String(p.minute).padStart(2, '0')}` } const showSeconds = format === 'HH:mm:ss' const effective12Hour = format === 'hh:mm A' ? true : use12Hours ? true : !use24Hour const hour12 = parts ? (parts.hour24 % 12 === 0 ? 12 : parts.hour24 % 12) : null const period = parts && parts.hour24 >= 12 ? 'PM' : 'AM' const minBound = parse(minTime ?? '') ?? null const maxBound = parse(maxTime ?? '') ?? null function withinBounds(p: TimeParts) { const m = toMinutes(p) if (minBound && m < toMinutes(minBound)) return false if (maxBound && m > toMinutes(maxBound)) return false return true } const disabledHoursSet = new Set(disabledHours ? disabledHours() : []) const disabledMinutesSet = new Set(disabledMinutes ? disabledMinutes(parts?.hour24 ?? 0) : []) const disabledSecondsSet = new Set( disabledSeconds ? disabledSeconds(parts?.hour24 ?? 0, parts?.minute ?? 0) : [], ) function isHourDisabledItem(h: number) { if (disabledHoursSet.has(h)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } if (effective12Hour) { const isPM = period === 'PM' return !withinBounds({ hour24: (h % 12) + (isPM ? 12 : 0), minute: cur.minute, second: cur.second }) } return !withinBounds({ hour24: h, minute: cur.minute, second: cur.second }) } function isMinuteDisabledItem(m: number) { if (disabledMinutesSet.has(m)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } return !withinBounds({ hour24: cur.hour24, minute: m, second: cur.second }) } function isSecondDisabledItem(s: number) { if (disabledSecondsSet.has(s)) return true const cur = parts ?? { hour24: 0, minute: 0, second: 0 } return !withinBounds({ hour24: cur.hour24, minute: cur.minute, second: s }) } const hourStepN = Math.max(1, hourStep) const hours12List = Array.from({ length: 12 }, (_, i) => i + 1) .filter((h) => (h - 1) % hourStepN === 0) .filter((h) => !hideDisabledOptions || !isHourDisabledItem(h)) const hours24List = Array.from({ length: 24 }, (_, i) => i) .filter((h) => h % hourStepN === 0) .filter((h) => !hideDisabledOptions || !isHourDisabledItem(h)) const minStepN = Math.max(1, Math.min(60, minuteStep)) const minutesList = Array.from({ length: Math.ceil(60 / minStepN) }, (_, i) => i * minStepN).filter( (m) => !hideDisabledOptions || !isMinuteDisabledItem(m), ) const secStepN = Math.max(1, Math.min(60, secondStep)) const secondsList = Array.from({ length: Math.ceil(60 / secStepN) }, (_, i) => i * secStepN).filter( (s) => !hideDisabledOptions || !isSecondDisabledItem(s), ) function commit(p: TimeParts) { if (!withinBounds(p)) return onValueChange?.(format24(p)) } function pickHour12(h: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } const isPM = period === 'PM' commit({ hour24: (h % 12) + (isPM ? 12 : 0), minute: cur.minute, second: cur.second }) } function pickHour24(h: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: h, minute: cur.minute, second: cur.second }) } function pickMinute(m: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: cur.hour24, minute: m, second: cur.second }) } function pickSecond(s: number) { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } commit({ hour24: cur.hour24, minute: cur.minute, second: s }) } function pickPeriod(p: 'AM' | 'PM') { const cur = parts ?? { hour24: 0, minute: 0, second: 0 } const h12 = cur.hour24 % 12 commit({ hour24: h12 + (p === 'PM' ? 12 : 0), minute: cur.minute, second: cur.second }) } const hourCol = React.useRef<HTMLDivElement | null>(null) const minCol = React.useRef<HTMLDivElement | null>(null) const secCol = React.useRef<HTMLDivElement | null>(null) React.useEffect(() => { if (!visible) return const raf = requestAnimationFrame(() => { hourCol.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' }) minCol.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' }) secCol.current?.querySelector('[data-active="true"]')?.scrollIntoView({ block: 'center' }) }) return () => cancelAnimationFrame(raf) }, [visible, value]) function isHourActive(h: number) { if (!parts) return false return effective12Hour ? hour12 === h : parts.hour24 === h } function isHourDisabled(h: number) { if (!hideDisabledOptions) return isHourDisabledItem(h) return false } function isMinuteDisabled(m: number) { if (!hideDisabledOptions) return isMinuteDisabledItem(m) return false } function isSecondDisabled(s: number) { if (!hideDisabledOptions) return isSecondDisabledItem(s) return false } return ( <div className="flex divide-x" data-uipkge="" data-slot="time-columns"> <ScrollArea ref={hourCol} className="h-56"> <div className="flex w-14 flex-col p-1"> {(effective12Hour ? hours12List : hours24List).map((h) => ( <button key={h} type="button" data-active={isHourActive(h)} disabled={isHourDisabled(h)} className={cn( 'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent', isHourActive(h) ? 'bg-primary text-primary-foreground hover:bg-primary' : '', )} onClick={() => (effective12Hour ? pickHour12(h) : pickHour24(h))} > {String(h).padStart(2, '0')} </button> ))} </div> </ScrollArea> <ScrollArea ref={minCol} className="h-56"> <div className="flex w-14 flex-col p-1"> {minutesList.map((m) => ( <button key={m} type="button" data-active={parts?.minute === m} disabled={isMinuteDisabled(m)} className={cn( 'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent', parts?.minute === m ? 'bg-primary text-primary-foreground hover:bg-primary' : '', )} onClick={() => pickMinute(m)} > {String(m).padStart(2, '0')} </button> ))} </div> </ScrollArea> {showSeconds && ( <ScrollArea ref={secCol} className="h-56"> <div className="flex w-14 flex-col p-1"> {secondsList.map((s) => ( <button key={s} type="button" data-active={parts?.second === s} disabled={isSecondDisabled(s)} className={cn( 'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm tabular-nums transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:opacity-30 disabled:hover:bg-transparent', parts?.second === s ? 'bg-primary text-primary-foreground hover:bg-primary' : '', )} onClick={() => pickSecond(s)} > {String(s).padStart(2, '0')} </button> ))} </div> </ScrollArea> )} {effective12Hour && ( <div className="flex w-12 flex-col p-1"> {(['AM', 'PM'] as const).map((p) => ( <button key={p} type="button" className={cn( 'hover:bg-accent focus-visible:ring-ring rounded px-2 py-1 text-center text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none', period === p ? 'bg-primary text-primary-foreground hover:bg-primary' : '', )} onClick={() => pickPeriod(p)} > {p} </button> ))} </div> )} </div> ) } // ---- TimePicker ---- export interface TimePickerProps { /** Controlled value as 24h HH:mm or HH:mm:ss. Empty string = no selection. */ value?: string /** Uncontrolled initial value. */ defaultValue?: string onValueChange?: (value: string) => void placeholder?: string disabled?: boolean readOnly?: boolean clearable?: boolean allowClear?: boolean minuteStep?: number hourStep?: number secondStep?: number /** Backward-compat: when true, render 24-hour selector. */ use24Hour?: boolean /** When true, show AM/PM selector. */ use12Hours?: boolean minTime?: string maxTime?: string format?: TimeFormat disabledHours?: () => number[] disabledMinutes?: (selectedHour: number) => number[] disabledSeconds?: (selectedHour: number, selectedMinute: number) => number[] hideDisabledOptions?: boolean presets?: TimePreset[] size?: TimePickerSize status?: TimePickerStatus triggerClassName?: string className?: string } const sizeClasses: Record<TimePickerSize, string> = { small: 'h-8 text-xs px-2.5 py-1', middle: 'h-9 text-sm px-3 py-1.5', large: 'h-11 text-base px-4 py-2', } const statusClasses: Record<string, string> = { error: 'border-destructive focus-visible:ring-destructive', warning: 'border-warning focus-visible:ring-warning', } const TimePicker = React.forwardRef<HTMLButtonElement, TimePickerProps>( ( { value, defaultValue = '', onValueChange, placeholder = 'Pick a time', disabled = false, readOnly = false, clearable = true, allowClear, minuteStep = 5, hourStep = 1, secondStep = 1, use24Hour = false, use12Hours = false, minTime, maxTime, format = 'HH:mm', disabledHours, disabledMinutes, disabledSeconds, hideDisabledOptions = false, presets = [], size = 'middle', status, triggerClassName, className, }, ref, ) => { const [open, setOpen] = React.useState(false) const isControlled = value !== undefined const [internal, setInternal] = React.useState<string>(defaultValue) const currentValue = isControlled ? value : internal const effectiveAllowClear = allowClear !== undefined ? allowClear : clearable const parsed = parse(currentValue) function formatDisplay(h: number, m: number, s: number) { if (format === 'hh:mm A') { const h12 = h % 12 === 0 ? 12 : h % 12 const p = h >= 12 ? 'PM' : 'AM' return `${String(h12).padStart(2, '0')}:${String(m).padStart(2, '0')} ${p}` } if (format === 'HH:mm:ss') { return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` } return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` } const display = parsed ? formatDisplay(parsed.hour24, parsed.minute, parsed.second) : '' function emitTime(v: string) { if (!isControlled) setInternal(v) onValueChange?.(v) } function pickNow() { if (readOnly) return const d = new Date() const sStep = Math.max(1, secondStep) const rawS = d.getSeconds() const snappedS = Math.round(rawS / sStep) * sStep const secCarry = Math.floor(snappedS / 60) const s = snappedS % 60 const mStep = Math.max(1, minuteStep) const rawM = d.getMinutes() + secCarry const snappedM = Math.round(rawM / mStep) * mStep const minCarry = Math.floor(snappedM / 60) const min = snappedM % 60 const h = d.getHours() + minCarry const hourStr = String(h).padStart(2, '0') const minStr = String(min).padStart(2, '0') const secStr = String(s).padStart(2, '0') emitTime(format === 'HH:mm:ss' ? `${hourStr}:${minStr}:${secStr}` : `${hourStr}:${minStr}`) } function applyPreset(preset: TimePreset) { if (readOnly) return emitTime(preset.value) setOpen(false) } function clear(event: React.MouseEvent) { event.stopPropagation() if (disabled || readOnly) return emitTime('') } const triggerClasses = cn( 'min-w-[160px] justify-start gap-2 text-left font-normal', !parsed && 'text-muted-foreground', sizeClasses[size], status && statusClasses[status], triggerClassName, className, ) return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button ref={ref} type="button" variant="outline" disabled={disabled} className={triggerClasses} data-uipkge="" data-slot="time-picker" > <Clock className="size-4 shrink-0" /> <span className="flex-1 truncate">{display || placeholder}</span> {effectiveAllowClear && parsed && !disabled && !readOnly && ( <button type="button" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-1 inline-flex size-5 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none" aria-label="Clear time" onClick={clear} > <X className="size-3.5" aria-hidden="true" /> </button> )} </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> {presets.length > 0 && ( <div className="flex flex-col gap-0.5 border-b p-2"> {presets.map((p) => ( <button key={p.label} type="button" className="hover:bg-accent focus-visible:ring-ring rounded-md px-2 py-1.5 text-left text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" onClick={() => applyPreset(p)} > {p.label} </button> ))} </div> )} <div className="flex items-center justify-between border-b px-3 py-2"> <span className="text-muted-foreground text-xs tracking-widest uppercase">Time</span> <button type="button" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring text-xs focus-visible:ring-2 focus-visible:outline-none" onClick={pickNow} > Now </button> </div> <TimeColumns value={currentValue} use24Hour={use24Hour} use12Hours={use12Hours} minuteStep={minuteStep} hourStep={hourStep} secondStep={secondStep} minTime={minTime} maxTime={maxTime} format={format} disabledHours={disabledHours} disabledMinutes={disabledMinutes} disabledSeconds={disabledSeconds} hideDisabledOptions={hideDisabledOptions} visible={open} onValueChange={emitTime} /> </PopoverContent> </Popover> ) }, ) TimePicker.displayName = 'TimePicker' export { TimePicker, TimeColumns } -
components/ui/time-picker/index.ts 0.2 kB
export { TimePicker, TimeColumns, type TimePickerProps, type TimeColumnsProps, type TimePreset, type TimeParts, type TimeFormat, type TimePickerSize, type TimePickerStatus, } from './time-picker'
Raw manifest: https://react.uipkge.dev/r/react/time-picker.json