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

components/datetime-picker/single-datepicker.tsx
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

components/datetime-picker/time-picker.tsx
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

Edit on GitHub

Last updated on 3/27/2025

On this page