Datetime Picker
A component that allows users to select both date and time values through an intuitive interface, supporting various formats and time zones.
Single Datetime Picker
Range Datetime Picker
Time Picker
::
Automatic Installation
This method is still working on progress, please use manual installation for now.
Manual Installation
Install dependencies
npm install radix-ui lucide-react date-fns react-day-picker
Date Picker Component
import { format, startOfMonth, startOfYear, subDays } from 'date-fns'
import { type Locale, enUS } from 'date-fns/locale'
import * as Lucide from 'lucide-react'
import * as React from 'react'
import { clx } from 'twistail-utils'
import { Button } from '#/components/button'
import { Calendar } from '#/components/calendar'
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxTrigger,
ListboxValue,
} from '#/components/listbox'
import { Popover, PopoverContent, PopoverTrigger } from '#/components/popover'
import { singleDatePickerStyles } from './datetime-picker.css'
import { TimePicker } from './time-picker'
import { type Granularity } from './time-utils'
interface DatePreset {
label: string
date: Date
}
interface SingleDatePickerProps {
inlinePresets?: boolean
internalPresets?: boolean
presets?: DatePreset[]
locale?: Locale
withTimePicker?: boolean
displayFormat?: string
granularity?: Granularity
hourCycle?: 12 | 24
value?: Date
onChange?: (date: Date | undefined) => void
placeholder?: string
className?: string
}
const defaultPresets: DatePreset[] = [
{
label: 'Today',
date: new Date(),
},
{
label: 'Yesterday',
date: subDays(new Date(), 1),
},
{
label: 'Start of Week',
date: (() => {
const date = new Date()
const day = date.getDay()
const diff = date.getDate() - day + (day === 0 ? -6 : 1)
return new Date(date.setDate(diff))
})(),
},
{
label: 'Start of Month',
date: startOfMonth(new Date()),
},
{
label: 'Start of Year',
date: startOfYear(new Date()),
},
]
function SingleDatePicker({
inlinePresets,
internalPresets,
presets = defaultPresets,
locale = enUS,
withTimePicker = false,
displayFormat,
granularity = 'second',
hourCycle = 24,
value,
onChange,
className,
placeholder = 'Pick a date',
}: SingleDatePickerProps) {
const [date, setDate] = React.useState<Date | undefined>(value)
const [month, setMonth] = React.useState<Date>(value || new Date())
React.useEffect(() => {
setDate(value)
}, [value])
const handlePresetSelect = (preset: DatePreset) => {
const newDate = new Date(preset.date)
if (date) {
// Maintain time from the previous date
newDate.setHours(
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds()
)
}
setDate(newDate)
onChange?.(newDate)
}
const handleInlinePresetsValueChange = (value: string) => {
const selectedPreset = presets.find((preset) => preset.label === value)
if (selectedPreset) {
handlePresetSelect(selectedPreset)
}
}
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) return
if (date) {
// Maintain time from the previous date
newDate.setHours(
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getMilliseconds()
)
}
setDate(newDate)
setMonth(newDate)
onChange?.(newDate)
}
const handleTimeChange = (newDate: Date | undefined) => {
if (!newDate) return
setDate(newDate)
onChange?.(newDate)
}
const getDisplayFormat = () => {
if (displayFormat) return displayFormat
if (!withTimePicker) return 'PPP'
if (hourCycle === 24) {
return `PPP HH:mm${granularity === 'second' ? ':ss' : ''}`
}
return `PPP hh:mm${granularity === 'second' ? ':ss' : ''} b`
}
const styles = singleDatePickerStyles({ inlinePresets, internalPresets })
return (
<div className={styles.root({ className })}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={clx(styles.trigger(), inlinePresets && styles.triggerWithInlinePresets())}
data-empty={!date}
>
<Lucide.Calendar className={styles.triggerIcon()} />
{date ? format(date, getDisplayFormat(), { locale }) : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className={styles.popoverContent()} align="center">
{internalPresets ? (
<>
<div className={styles.calendarWrapper()}>
<div className={styles.presetsContainer()}>
<div className={styles.presetsColumn()}>
{presets.map((preset) => (
<Button
key={preset.label}
onClick={() => handlePresetSelect(preset)}
className={styles.presetButton()}
variant="ghost"
size="sm"
>
{preset.label}
</Button>
))}
</div>
</div>
<Calendar
mode="single"
locale={locale}
month={month}
defaultMonth={date}
selected={date}
onSelect={handleDateSelect}
onMonthChange={setMonth}
autoFocus
/>
</div>
</>
) : (
<Calendar
mode="single"
locale={locale}
month={month}
defaultMonth={date}
selected={date}
onSelect={handleDateSelect}
onMonthChange={setMonth}
autoFocus
/>
)}
{withTimePicker && (
<div className="rounded-b-md border-border border-t p-0">
<TimePicker
date={date || new Date()}
className="flex items-center justify-center rounded-b-md"
onChange={handleTimeChange}
granularity={granularity}
hourCycle={hourCycle}
/>
</div>
)}
</PopoverContent>
</Popover>
{inlinePresets && (
<Listbox onValueChange={handleInlinePresetsValueChange}>
<ListboxTrigger className={styles.inlineListboxTrigger()}>
<ListboxValue placeholder="Select Date" />
</ListboxTrigger>
<ListboxContent position="popper">
{presets.map((preset) => (
<ListboxItem key={preset.label} value={preset.label}>
{preset.label}
</ListboxItem>
))}
</ListboxContent>
</Listbox>
)}
</div>
)
}
export { SingleDatePicker, type SingleDatePickerProps }
Time Picker Component
import * as Lucide from 'lucide-react'
import * as React from 'react'
import { Input } from '#/components/input'
import {
Listbox,
ListboxContent,
ListboxItem,
ListboxTrigger,
ListboxValue,
} from '#/components/listbox'
import { timePickerStyles } from './datetime-picker.css'
import type { Granularity, Period, TimePickerType } from './time-utils'
import { display12HourValue } from './time-utils'
import { getArrowByType, getDateByType, setDateByType } from './time-utils'
interface TimePickerProps {
date?: Date | null
onChange?: (date: Date | undefined) => void
hourCycle?: 12 | 24
/**
* Determines the smallest unit that is displayed in the time picker.
* Default is 'second'.
*/
granularity?: Granularity
className?: string
}
interface TimePickerRef {
minuteRef: HTMLInputElement | null
hourRef: HTMLInputElement | null
secondRef: HTMLInputElement | null
periodRef: HTMLButtonElement | null
}
const TimePicker = React.forwardRef<TimePickerRef, TimePickerProps>(
({ date, onChange, hourCycle = 24, granularity = 'second', className }, forwardedRef) => {
const minuteRef = React.useRef<HTMLInputElement>(null)
const hourRef = React.useRef<HTMLInputElement>(null)
const secondRef = React.useRef<HTMLInputElement>(null)
const periodRef = React.useRef<HTMLButtonElement>(null)
const [period, setPeriod] = React.useState<Period>(date && date.getHours() >= 12 ? 'PM' : 'AM')
React.useImperativeHandle(
forwardedRef,
() => ({
minuteRef: minuteRef.current,
hourRef: hourRef.current,
secondRef: secondRef.current,
periodRef: periodRef.current,
}),
[]
)
const styles = timePickerStyles({ hourCycle, granularity })
return (
<div className={styles.container({ className })}>
<div className={styles.timeInputGroup()}>
<label htmlFor="datetime-picker-hour-input" className={styles.label()}>
<Lucide.Clock className={styles.labelIcon()} />
</label>
<TimePickerInput
picker={hourCycle === 24 ? 'hours' : '12hours'}
date={date}
id="datetime-picker-hour-input"
onDateChange={onChange}
ref={hourRef}
period={period}
onRightFocus={() => minuteRef?.current?.focus()}
className={styles.input()}
/>
{(granularity === 'minute' || granularity === 'second') && (
<>
<span className={styles.separator()}>:</span>
<TimePickerInput
picker="minutes"
date={date}
onDateChange={onChange}
ref={minuteRef}
onLeftFocus={() => hourRef?.current?.focus()}
onRightFocus={() => secondRef?.current?.focus()}
className={styles.input()}
/>
</>
)}
{granularity === 'second' && (
<>
<span className={styles.separator()}>:</span>
<TimePickerInput
picker="seconds"
date={date}
onDateChange={onChange}
ref={secondRef}
onLeftFocus={() => minuteRef?.current?.focus()}
onRightFocus={() => periodRef?.current?.focus()}
className={styles.input()}
/>
</>
)}
</div>
{hourCycle === 12 && (
<TimePeriodSelect
period={period}
setPeriod={setPeriod}
date={date}
onDateChange={(date) => {
onChange?.(date)
if (date && date?.getHours() >= 12) {
setPeriod('PM')
} else {
setPeriod('AM')
}
}}
ref={periodRef}
onLeftFocus={() => secondRef?.current?.focus()}
className={styles.periodTrigger()}
/>
)}
</div>
)
}
)
interface TimePeriodSelectProps {
period: Period
setPeriod?: (m: Period) => void
date?: Date | null
onDateChange?: (date: Date | undefined) => void
onRightFocus?: () => void
onLeftFocus?: () => void
className?: string
}
const TimePeriodSelect = React.forwardRef<HTMLButtonElement, TimePeriodSelectProps>(
(
{ period, setPeriod, date, onDateChange, onLeftFocus, onRightFocus, className },
forwardedRef
) => {
const styles = timePickerStyles()
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'ArrowRight') onRightFocus?.()
if (e.key === 'ArrowLeft') onLeftFocus?.()
}
const handleValueChange = (value: Period) => {
setPeriod?.(value)
if (date) {
const tempDate = new Date(date)
const hours = display12HourValue(date.getHours())
onDateChange?.(
setDateByType(tempDate, hours.toString(), '12hours', period === 'AM' ? 'PM' : 'AM')
)
}
}
return (
<Listbox defaultValue={period} onValueChange={(value: Period) => handleValueChange(value)}>
<ListboxTrigger
ref={forwardedRef}
className={styles.periodTrigger({ className })}
onKeyDown={handleKeyDown}
>
<ListboxValue />
</ListboxTrigger>
<ListboxContent>
<ListboxItem value="AM" className={styles.periodItem()}>
AM
</ListboxItem>
<ListboxItem value="PM" className={styles.periodItem()}>
PM
</ListboxItem>
</ListboxContent>
</Listbox>
)
}
)
interface TimePickerInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
picker: TimePickerType
date?: Date | null
onDateChange?: (date: Date | undefined) => void
period?: Period
onRightFocus?: () => void
onLeftFocus?: () => void
}
const TimePickerInput = React.forwardRef<HTMLInputElement, TimePickerInputProps>(
(
{
className,
type = 'tel',
value,
id,
name,
date = new Date(new Date().setHours(0, 0, 0, 0)),
onDateChange,
onChange,
onKeyDown,
picker,
period,
onLeftFocus,
onRightFocus,
...props
},
forwardedRef
) => {
const [flag, setFlag] = React.useState<boolean>(false)
const [prevIntKey, setPrevIntKey] = React.useState<string>('0')
/**
* Allow the user to enter the second digit within 2 seconds
* otherwise start again with entering first digit
*/
React.useEffect(() => {
if (flag) {
const timer = setTimeout(() => {
setFlag(false)
}, 2000)
return () => clearTimeout(timer)
}
}, [flag])
const calculatedValue = React.useMemo(() => {
return getDateByType(date, picker)
}, [date, picker])
const calculateNewValue = (key: string) => {
/*
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
* The second entered digit will break the condition and the value will be set to 10-12.
*/
if (picker === '12hours') {
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') return `0${key}`
}
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Tab') return
e.preventDefault()
if (e.key === 'ArrowRight') onRightFocus?.()
if (e.key === 'ArrowLeft') onLeftFocus?.()
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
const step = e.key === 'ArrowUp' ? 1 : -1
const newValue = getArrowByType(calculatedValue, step, picker)
if (flag) setFlag(false)
const tempDate = date ? new Date(date) : new Date()
onDateChange?.(setDateByType(tempDate, newValue, picker, period))
}
if (e.key >= '0' && e.key <= '9') {
if (picker === '12hours') setPrevIntKey(e.key)
const newValue = calculateNewValue(e.key)
if (flag) onRightFocus?.()
setFlag((prev) => !prev)
const tempDate = date ? new Date(date) : new Date()
onDateChange?.(setDateByType(tempDate, newValue, picker, period))
}
}
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
onChange?.(e)
}
return (
<Input
ref={forwardedRef}
id={id || picker}
name={name || picker}
className={className}
value={value || calculatedValue}
onChange={handleOnChange}
type={type}
inputMode="decimal"
onKeyDown={(e) => {
onKeyDown?.(e)
handleKeyDown(e)
}}
aria-label={`${picker} input`}
{...props}
/>
)
}
)
TimePickerInput.displayName = 'TimePickerInput'
TimePeriodSelect.displayName = 'TimePeriodSelect'
TimePicker.displayName = 'TimePicker'
export { TimePicker, TimePeriodSelect, TimePickerInput }
export type { TimePickerProps, TimePickerRef, TimePickerInputProps }
Usage
Imports
import { RangeDatePicker, SingleDatePicker, TimePicker } from '#/components/datetime-picker'
Example
Browse the Storybook for more examples.
Credits
- datetime-picker by shadcn-ui expansions, licensed under MIT License.
- shadcn-datetime-picker by Bui Dac Huy.
Edit on GitHub
Last updated on 3/27/2025