Glrk UI

Date Picker

A popover-based date picker wrapping Calendar and Popover. Supports single, range, and multiple selection modes with smart auto-close behavior. Includes a DateTimePicker variant with a scrollable time slot panel.

Single
mode=single — select one date, closes on pick (default)
Range
mode=range — closes when both from and to are set
Multiple
mode=multiple — toggle individual dates, stays open
Custom format
dateFormat="MMM dd, yyyy"
Custom trigger
triggerProps — ghost variant with dashed border
Controlled
open / onOpenChange — external state control
Date & Time
default — 24h, 5 min step
hourFormat={12} — AM/PM
minuteStep={15}
Date & Time constraints
disablePast — blocks past dates and times
minDate / maxDate — ±3 / +7 days from today
minTime — earliest = now + 30 min

Installation

npx shadcn@latest add @glrk-ui/date-picker

If you haven't set up the prerequisites yet, check out Prerequest section.

Copy and paste the following code into your project.

ui/date-picker/utils.ts
import { format } from 'date-fns'
import { type DateRange } from '@daypicker/react'

export type MinuteStep = 1 | 5 | 10 | 15 | 20 | 30
export type HourFormat = 12 | 24

export type Selected = Date | Date[] | DateRange | undefined

export function formatSelected(selected: Selected, fmt: string): string | null {
  if (!selected) return null
  if (selected instanceof Date) return format(selected, fmt)
  if (Array.isArray(selected)) {
    if (!selected.length) return null
    return selected.length === 1 ? format(selected[0]!, fmt) : `${selected.length} dates selected`
  }
  const range = selected as DateRange
  if (!range.from) return null
  if (!range.to) return format(range.from, fmt)
  return `${format(range.from, fmt)} - ${format(range.to, fmt)}`
}

export function getDefaultMonth(selected: Selected): Date | undefined {
  if (!selected) return undefined
  if (selected instanceof Date) return selected
  if (Array.isArray(selected)) return selected[0]
  return (selected as DateRange).from
}

export function snapToStep(minutes: number, step: number): number {
  const snapped = Math.round(minutes / step) * step
  return snapped >= 60 ? 0 : snapped
}

export function snapTimeForward(
  baseHour: number,
  minMinute: number,
  step: number,
): { hours: number; minutes: number } {
  const snapped = Math.ceil(minMinute / step) * step
  if (snapped >= 60) return { hours: baseHour + 1, minutes: 0 }
  return { hours: baseHour, minutes: snapped }
}

export function defaultNow(step: number): Date {
  const n = new Date()
  n.setMinutes(snapToStep(n.getMinutes(), step))
  n.setSeconds(0)
  n.setMilliseconds(0)
  return n
}

export function startOfDay(date: Date): number {
  const d = new Date(date)
  d.setHours(0, 0, 0, 0)
  return d.getTime()
}
ui/date-picker/calendar-dropdown.tsx
import * as React from 'react'
import { type DropdownProps } from '@daypicker/react'

import { cn } from '@/lib/utils'

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

function CalendarDropdown({ options = [], value, onChange, disabled, className }: DropdownProps) {
  const stringValue = value !== undefined ? String(value) : undefined
  const labelMap = React.useMemo(
    () => Object.fromEntries(options.map(o => [String(o.value), o.label])),
    [options],
  )

  function handleValueChange(val: string | null) {
    if (!onChange || val === null) return
    const event = { target: { value: val } } as React.ChangeEvent<HTMLSelectElement>
    onChange(event)
  }

  return (
    <Select
      value={stringValue}
      onValueChange={handleValueChange as (val: string | null) => void}
      disabled={disabled}
    >
      <SelectTrigger
        className={cn(
          'h-7 border-0 shadow-none bg-transparent hover:bg-accent px-2 pr-1 font-medium gap-0.5 text-sm',
          className,
        )}
      >
        <SelectValue>{() => labelMap[stringValue ?? ''] ?? stringValue}</SelectValue>
      </SelectTrigger>
      <SelectContent className="min-w-16">
        {options.map(({ value: val, label, disabled: optDisabled }) => (
          <SelectItem
            key={val}
            value={String(val)}
            disabled={optDisabled}
            className="px-2! [&_svg]:hidden"
          >
            {label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}

export { CalendarDropdown }
ui/date-picker/calendar-day-button.tsx
import * as React from 'react'
import { getDefaultClassNames, type DayButton, type Locale } from '@daypicker/react'

import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'

function CalendarDayButton({
  className,
  day,
  modifiers,
  locale,
  ...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
  const defaultClassNames = getDefaultClassNames()

  const ref = React.useRef<HTMLButtonElement>(null)
  React.useEffect(() => {
    if (modifiers.focused) ref.current?.focus()
  }, [modifiers.focused])

  return (
    <Button
      variant="ghost"
      size="icon"
      data-day={day.date.toLocaleDateString(locale?.code)}
      data-selected-single={
        modifiers.selected &&
        !modifiers.range_start &&
        !modifiers.range_end &&
        !modifiers.range_middle
      }
      data-range-start={modifiers.range_start}
      data-range-end={modifiers.range_end}
      data-range-middle={modifiers.range_middle}
      className={cn(
        'relative flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
        defaultClassNames.day,
        className,
      )}
      {...props}
    />
  )
}

export { CalendarDayButton }
ui/date-picker/calendar.tsx
'use client'

import * as React from 'react'
import { DayPicker, getDefaultClassNames } from '@daypicker/react'
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from 'lucide-react'

import { cn } from '@/lib/utils'

import { Button, buttonVariants } from '@/components/ui/button'
import { CalendarDayButton } from './calendar-day-button'
import { CalendarDropdown } from './calendar-dropdown'

function Calendar({
  locale,
  className,
  classNames,
  formatters,
  components,
  captionLayout = 'label',
  buttonVariant = 'ghost',
  showOutsideDays = true,
  ...props
}: React.ComponentProps<typeof DayPicker> & {
  buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
  const defaultClassNames = getDefaultClassNames()

  return (
    <DayPicker
      showOutsideDays={showOutsideDays}
      className={cn(
        'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
        String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
        String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
        className,
      )}
      captionLayout={captionLayout}
      locale={locale}
      formatters={{
        formatMonthDropdown: date => date.toLocaleString(locale?.code, { month: 'short' }),
        ...formatters,
      }}
      classNames={{
        root: cn('w-fit', defaultClassNames.root),
        months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
        month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
        nav: cn(
          'pointer-events-none absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
          defaultClassNames.nav,
        ),
        button_previous: cn(
          buttonVariants({ variant: buttonVariant }),
          'pointer-events-auto size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
          defaultClassNames.button_previous,
        ),
        button_next: cn(
          buttonVariants({ variant: buttonVariant }),
          'pointer-events-auto size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
          defaultClassNames.button_next,
        ),
        month_caption: cn(
          'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
          defaultClassNames.month_caption,
        ),
        dropdowns: cn(
          'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
          defaultClassNames.dropdowns,
        ),
        dropdown_root: cn('relative rounded-(--cell-radius)', defaultClassNames.dropdown_root),
        dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
        caption_label: cn(
          'font-medium select-none',
          captionLayout === 'label'
            ? 'text-sm'
            : 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
          defaultClassNames.caption_label,
        ),
        month_grid: 'w-full border-collapse',
        weekdays: cn('flex', defaultClassNames.weekdays),
        weekday: cn(
          'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
          defaultClassNames.weekday,
        ),
        week: cn('mt-2 flex w-full', defaultClassNames.week),
        week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
        week_number: cn(
          'text-[0.8rem] text-muted-foreground select-none',
          defaultClassNames.week_number,
        ),
        day: cn(
          'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
          props.showWeekNumber
            ? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
            : '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
          defaultClassNames.day,
        ),
        range_start: cn(
          'relative rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
          defaultClassNames.range_start,
        ),
        range_middle: cn('rounded-none', defaultClassNames.range_middle),
        range_end: cn(
          'relative rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
          defaultClassNames.range_end,
        ),
        today: cn(
          'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
          defaultClassNames.today,
        ),
        outside: cn(
          'text-muted-foreground aria-selected:text-muted-foreground',
          defaultClassNames.outside,
        ),
        disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
        hidden: cn('invisible', defaultClassNames.hidden),
        ...classNames,
      }}
      components={{
        Dropdown: CalendarDropdown,
        Root: ({ className, rootRef, ...props }) => {
          return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
        },
        Chevron: ({ className, orientation, ...props }) => {
          if (orientation === 'left') {
            return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
          }

          if (orientation === 'right') {
            return <ChevronRightIcon className={cn('size-4', className)} {...props} />
          }

          return <ChevronDownIcon className={cn('size-4', className)} {...props} />
        },
        DayButton: props => <CalendarDayButton locale={locale} {...props} />,
        WeekNumber: ({ children, ...props }) => {
          return (
            <td {...props}>
              <div className="flex size-(--cell-size) items-center justify-center text-center">
                {children}
              </div>
            </td>
          )
        },
        ...components,
      }}
      {...props}
    />
  )
}

export { Calendar }
ui/date-picker/date-picker.tsx
'use client'

import * as React from 'react'
import { type DateRange } from '@daypicker/react'
import { CalendarIcon } from 'lucide-react'

import { type Selected, formatSelected, getDefaultMonth } from './utils'
import { cn } from '@/lib/utils'

import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'

import { Calendar } from './calendar'

type DatePickerProps = Omit<
  React.ComponentProps<typeof Calendar>,
  'mode' | 'selected' | 'onSelect'
> & {
  open?: boolean
  mode?: 'single' | 'multiple' | 'range'
  align?: React.ComponentProps<typeof PopoverContent>['align']
  selected?: Selected
  dateFormat?: string
  placeholder?: string
  triggerProps?: Omit<React.ComponentProps<typeof Button>, 'children'>
  onOpenChange?: (open: boolean) => void
  onSelect?: (...args: any[]) => void
}

function DatePicker({
  mode,
  open,
  selected,
  triggerProps,
  align = 'start',
  dateFormat = 'dd/MM/yyyy',
  placeholder = 'Pick a date',
  onSelect,
  onOpenChange,
  ...calendarProps
}: DatePickerProps) {
  const [internalOpen, setInternalOpen] = React.useState(false)
  const isControlled = open !== undefined
  const open_ = isControlled ? open : internalOpen

  function handleOpenChange(next: boolean) {
    if (!isControlled) setInternalOpen(next)
    onOpenChange?.(next)
  }

  function handleSelect(...args: any[]) {
    onSelect?.(...args)
    const effectiveMode = mode ?? 'single'
    if (effectiveMode === 'single') {
      handleOpenChange(false)
    } else if (effectiveMode === 'range') {
      const val = args[0] as DateRange | undefined
      if (val?.from && val?.to) handleOpenChange(false)
    }
  }

  const { className: triggerClassName, ...restTriggerProps } = triggerProps ?? {}
  const label = formatSelected(selected, dateFormat)

  return (
    <Popover open={open_} onOpenChange={handleOpenChange}>
      <PopoverTrigger
        render={
          <Button
            variant="outline"
            {...restTriggerProps}
            className={cn(
              'w-full pl-3 text-left font-normal shadow-xs',
              !label && 'text-muted-foreground',
              triggerClassName,
            )}
          >
            {label ?? <span>{placeholder}</span>}
            <CalendarIcon className="ml-auto h-4 w-4 opacity-50" aria-hidden="true" />
          </Button>
        }
      />

      <PopoverContent className="w-auto p-0" align={align}>
        <Calendar
          {...({
            autoFocus: true,
            captionLayout: 'dropdown',
            defaultMonth: getDefaultMonth(selected),
            mode: mode ?? 'single',
            selected,
            onSelect: handleSelect,
            ...calendarProps,
          } as React.ComponentProps<typeof Calendar>)}
        />
      </PopoverContent>
    </Popover>
  )
}

export { DatePicker }
export type { DatePickerProps }
ui/date-picker/date-time-picker.tsx
'use client'

import * as React from 'react'
import { CalendarIcon, Clock } from 'lucide-react'
import { format } from 'date-fns'

import { cn } from '@/lib/utils'
import {
  type MinuteStep,
  type HourFormat,
  snapToStep,
  snapTimeForward,
  defaultNow,
  startOfDay,
} from './utils'

import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'

import { Calendar } from './calendar'

type DateTimePickerProps = Omit<
  React.ComponentProps<typeof Calendar>,
  'mode' | 'selected' | 'onSelect' | 'disabled'
> & {
  open?: boolean
  align?: React.ComponentProps<typeof PopoverContent>['align']
  minDate?: Date
  maxDate?: Date
  minTime?: Date
  selected?: Date
  placeholder?: string
  dateFormat?: string
  minuteStep?: MinuteStep
  hourFormat?: HourFormat
  disablePast?: boolean
  triggerProps?: Omit<React.ComponentProps<typeof Button>, 'children'>
  onSelect?: (date: Date | undefined) => void
  onOpenChange?: (open: boolean) => void
}

function DateTimePicker({
  open,
  minDate,
  maxDate,
  minTime,
  selected,
  dateFormat,
  triggerProps,
  align = 'start',
  minuteStep = 5,
  hourFormat = 24,
  placeholder = 'Pick date & time',
  disablePast = false,
  onSelect,
  onOpenChange,
  ...calendarProps
}: DateTimePickerProps) {
  const [internalOpen, setInternalOpen] = React.useState(false)
  const isControlled = open !== undefined
  const open_ = isControlled ? open : internalOpen

  const [internalDate, setInternalDate] = React.useState<Date | undefined>(selected)
  const slotRefs = React.useRef<Record<number, HTMLButtonElement | null>>({})

  React.useEffect(() => {
    setInternalDate(selected)
  }, [selected])

  function handleOpenChange(next: boolean) {
    if (!isControlled) setInternalOpen(next)
    onOpenChange?.(next)
  }

  const effectiveMinTime = React.useMemo(() => {
    const candidates: Date[] = []
    if (disablePast) candidates.push(new Date())
    if (minTime) candidates.push(minTime)
    if (!candidates.length) return null
    return candidates.reduce((a, b) => (a > b ? a : b))
  }, [disablePast, minTime])

  const effectiveMinTotalMinutes = React.useMemo(
    () =>
      effectiveMinTime ? effectiveMinTime.getHours() * 60 + effectiveMinTime.getMinutes() : -1,
    [effectiveMinTime],
  )

  const isMinTimeDay = React.useMemo(() => {
    if (!effectiveMinTime) return false
    const reference = internalDate ?? new Date()
    return startOfDay(effectiveMinTime) === startOfDay(reference)
  }, [effectiveMinTime, internalDate])

  function commit(next: Date | undefined) {
    setInternalDate(next)
    onSelect?.(next)
  }

  function handleDaySelect(day: Date | undefined) {
    if (!day) return
    const base = internalDate ?? new Date()
    const merged = new Date(day)
    merged.setHours(base.getHours())
    merged.setMinutes(snapToStep(base.getMinutes(), minuteStep))
    merged.setSeconds(0)
    merged.setMilliseconds(0)
    if (effectiveMinTime) {
      if (startOfDay(merged) === startOfDay(effectiveMinTime) && merged < effectiveMinTime) {
        const { hours, minutes } = snapTimeForward(
          effectiveMinTime.getHours(),
          effectiveMinTime.getMinutes(),
          minuteStep,
        )
        merged.setHours(hours)
        merged.setMinutes(minutes)
        merged.setSeconds(0)
      }
    }
    commit(merged)
  }

  const timeSlots = React.useMemo(() => {
    const total = Math.floor((24 * 60) / minuteStep)
    return Array.from({ length: total }, (_, i) => {
      const totalMinutes = i * minuteStep
      return { hour: Math.floor(totalMinutes / 60), minute: totalMinutes % 60 }
    })
  }, [minuteStep])

  function formatSlot(hour: number, minute: number): string {
    const mm = String(minute).padStart(2, '0')
    if (hourFormat === 24) return `${String(hour).padStart(2, '0')}:${mm}`
    const h12 = hour % 12 === 0 ? 12 : hour % 12
    return `${String(h12).padStart(2, '0')}:${mm} ${hour < 12 ? 'AM' : 'PM'}`
  }

  function isSlotDisabled(hour: number, minute: number): boolean {
    if (effectiveMinTotalMinutes < 0 || !isMinTimeDay) return false
    return hour * 60 + minute < effectiveMinTotalMinutes
  }

  const selectedSlotKey =
    internalDate !== undefined ? internalDate.getHours() * 60 + internalDate.getMinutes() : -1

  React.useEffect(() => {
    if (selectedSlotKey >= 0) {
      slotRefs.current[selectedSlotKey]?.scrollIntoView({ block: 'center' })
    }
  }, [selectedSlotKey, open])

  function selectSlot(hour: number, minute: number) {
    if (isSlotDisabled(hour, minute)) return
    const base = internalDate ?? defaultNow(minuteStep)
    const next = new Date(base)
    next.setHours(hour)
    next.setMinutes(minute)
    next.setSeconds(0)
    next.setMilliseconds(0)
    commit(next)
  }

  function setNow() {
    const n = new Date()
    n.setMinutes(snapToStep(n.getMinutes(), minuteStep))
    n.setSeconds(0)
    n.setMilliseconds(0)
    if (effectiveMinTime && n < effectiveMinTime) {
      const { hours, minutes } = snapTimeForward(
        effectiveMinTime.getHours(),
        effectiveMinTime.getMinutes(),
        minuteStep,
      )
      n.setFullYear(
        effectiveMinTime.getFullYear(),
        effectiveMinTime.getMonth(),
        effectiveMinTime.getDate(),
      )
      n.setHours(hours)
      n.setMinutes(minutes)
      n.setSeconds(0)
    }
    commit(n)
  }

  const calendarDisabled = React.useMemo(() => {
    const todayMs = disablePast ? startOfDay(new Date()) : -1
    const minTimeMs = minTime ? startOfDay(minTime) : -1
    const minDateMs = minDate ? startOfDay(minDate) : -1
    const maxDateMs = maxDate ? new Date(maxDate).setHours(23, 59, 59, 999) : Infinity

    return (date: Date) => {
      const d = startOfDay(date)
      if (todayMs >= 0 && d < todayMs) return true
      if (minTimeMs >= 0 && d < minTimeMs) return true
      if (minDateMs >= 0 && d < minDateMs) return true
      if (date.getTime() > maxDateMs) return true
      return false
    }
  }, [disablePast, minTime, minDate, maxDate])

  const fmt = dateFormat ?? (hourFormat === 24 ? 'dd/MM/yyyy HH:mm' : 'dd/MM/yyyy hh:mm a')
  const { className: triggerClassName, ...restTriggerProps } = triggerProps ?? {}

  return (
    <Popover open={open_} onOpenChange={handleOpenChange}>
      <PopoverTrigger
        render={
          <Button
            variant="outline"
            {...restTriggerProps}
            className={cn(
              'w-full pl-3 text-left font-normal shadow-xs',
              !internalDate && 'text-muted-foreground',
              triggerClassName,
            )}
          >
            {internalDate ? format(internalDate, fmt) : <span>{placeholder}</span>}
            <CalendarIcon className="ml-auto h-4 w-4 opacity-50" aria-hidden="true" />
          </Button>
        }
      />

      <PopoverContent className="w-auto p-0 overflow-hidden" align={align} sideOffset={8}>
        <div className="flex flex-col">
          <div className="flex">
            <div>
              <Calendar
                {...({
                  autoFocus: true,
                  mode: 'single',
                  selected: internalDate,
                  onSelect: handleDaySelect,
                  disabled: calendarDisabled,
                  ...calendarProps,
                } as React.ComponentProps<typeof Calendar>)}
              />
            </div>

            <div
              className={cn(
                'flex flex-col border-l overflow-hidden',
                hourFormat === 12 ? 'w-24' : 'w-20',
              )}
            >
              <div className="flex items-center justify-center gap-1.5 px-2 pt-3 pb-2 text-xs font-medium">
                <Clock className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
                <span>Time</span>
              </div>

              <ScrollArea className="flex-1 min-h-0 max-h-64 pb-2">
                <div role="listbox" aria-label="Select time" className="flex flex-col gap-0.5 px-1">
                  {timeSlots.map(({ hour, minute }) => {
                    const key = hour * 60 + minute
                    const active = key === selectedSlotKey
                    const disabled = isSlotDisabled(hour, minute)
                    return (
                      <button
                        key={key}
                        ref={el => {
                          slotRefs.current[key] = el
                        }}
                        type="button"
                        role="option"
                        aria-selected={active}
                        disabled={disabled}
                        onClick={() => selectSlot(hour, minute)}
                        className={cn(
                          'h-7 w-full rounded-md px-1.5 text-center text-xs tabular-nums transition-colors',
                          'hover:bg-accent hover:text-accent-foreground',
                          'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
                          active &&
                            'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground shadow-xs',
                          disabled && 'pointer-events-none opacity-30',
                        )}
                      >
                        {formatSlot(hour, minute)}
                      </button>
                    )
                  })}
                </div>
              </ScrollArea>
            </div>
          </div>

          <div className="flex items-center gap-1 p-2 border-t">
            <Button
              type="button"
              variant="ghost"
              size="sm"
              onClick={setNow}
              className="h-8 px-2 text-xs"
            >
              Now
            </Button>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              onClick={() => commit(undefined)}
              className="h-8 px-2 text-xs ml-auto"
            >
              Clear
            </Button>
            <Button
              type="button"
              size="sm"
              disabled={!internalDate}
              onClick={() => handleOpenChange(false)}
              className="h-8 px-2 text-xs"
            >
              Done
            </Button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  )
}

export { DateTimePicker }
export type { DateTimePickerProps }

DatePicker

Single (default)

import { DatePicker } from "@/components/ui/date-picker"

const [date, setDate] = React.useState<Date | undefined>()

<DatePicker
  selected={date}
  onSelect={setDate}
  startMonth={new Date(2020, 0)}
  endMonth={new Date(2030, 11)}
/>

Range

Closes automatically once both from and to are selected:

import { type DateRange } from "@daypicker/react"

const [range, setRange] = React.useState<DateRange | undefined>()

<DatePicker
  mode="range"
  selected={range}
  onSelect={setRange}
  startMonth={new Date(2020, 0)}
  endMonth={new Date(2030, 11)}
/>

Multiple

Stays open — no auto-close when toggling individual dates:

const [dates, setDates] = React.useState<Date[] | undefined>()

<DatePicker
  mode="multiple"
  selected={dates}
  onSelect={setDates}
/>

Custom date format

dateFormat is a date-fns format string:

<DatePicker selected={date} onSelect={setDate} dateFormat="MMM dd, yyyy" />

Custom trigger

triggerProps forwards to the trigger Button — accepts all Button props except children:

<DatePicker
  triggerProps={{ variant: "ghost", className: "w-52 border border-dashed" }}
  placeholder="Pick a date..."
  selected={date}
  onSelect={setDate}
/>

Controlled open state

const [open, setOpen] = React.useState(false)

<DatePicker open={open} onOpenChange={setOpen} selected={date} onSelect={setDate} />
<DatePicker align="center" selected={date} onSelect={setDate} />
{/* align: "start" | "center" | "end" — default "start" */}

captionLayout="dropdown" is the default. Use startMonth / endMonth to limit the month/year dropdown range:

<DatePicker
  startMonth={new Date(2020, 0)}
  endMonth={new Date(2030, 11)}
  selected={date}
  onSelect={setDate}
/>

Label caption

<DatePicker captionLayout="label" selected={date} onSelect={setDate} />

DateTimePicker

Combines the calendar with a scrollable time slot panel in the same popover. Single mode only. Changing the day preserves the selected time; changing the time slot preserves the selected day.

Basic

import { DateTimePicker } from "@/components/ui/date-picker"

const [date, setDate] = React.useState<Date | undefined>()

<DateTimePicker
  selected={date}
  onSelect={setDate}
  startMonth={new Date(2020, 0)}
  endMonth={new Date(2030, 11)}
/>

12-hour format

<DateTimePicker
  selected={date}
  onSelect={setDate}
  hourFormat={12}
/>

Minute step

Controls the interval between time slots. Defaults to 5:

<DateTimePicker selected={date} onSelect={setDate} minuteStep={15} />
<DateTimePicker selected={date} onSelect={setDate} minuteStep={30} />

Allowed values: 1 | 5 | 10 | 15 | 20 | 30.

Custom date format

Defaults to "dd/MM/yyyy HH:mm" (24h) or "dd/MM/yyyy hh:mm a" (12h):

<DateTimePicker selected={date} onSelect={setDate} dateFormat="MMM dd, yyyy HH:mm" />

Disable past

Blocks all past dates in the calendar and all past time slots on today:

<DateTimePicker selected={date} onSelect={setDate} disablePast />

Date range constraint

<DateTimePicker
  selected={date}
  onSelect={setDate}
  minDate={new Date(2025, 0, 1)}
  maxDate={new Date(2025, 11, 31)}
/>

Minimum datetime

Disables dates before minTime's calendar day, and disables time slots before minTime on that day. Compose with disablePast — the later constraint wins:

const earliest = addMinutes(new Date(), 30)

<DateTimePicker
  selected={date}
  onSelect={setDate}
  minTime={earliest}
  disablePast
/>

Now / Clear / Done

The footer always shows three actions:

  • Now — snaps selection to the current time (rounded to minuteStep)
  • Clear — resets the value to undefined
  • Done — closes the popover

Controlled open state

<DateTimePicker open={open} onOpenChange={setOpen} selected={date} onSelect={setDate} />

Reference

DatePicker

Prop

Type

DateTimePicker

Prop

Type