rapidUI

Date Time Picker

Combined date and time picker in a popover.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/datetime-picker.json

Installs to @/components/ui/datetime-picker.

Manual installation

Install the dependencies:

pnpm add date-fns@^4.4.0 lucide-react@^1.48.0 react-day-picker@^10.0.1

Add the components it builds on: button, calendar, popover, scroll-area.

Copy the source into your project:

components/ui/datetime-picker.tsx
'use client'

import * as DateFns from 'date-fns'
import { CalendarIcon } from 'lucide-react'
import * as React from 'react'
import type { Matcher } from 'react-day-picker'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'

interface DateTimePickerProps {
	readonly value?: string
	readonly onChange?: (value: string) => void
	readonly placeholder?: string
	/** when true, the trigger button is disabled */
	readonly disabled?: boolean
	/** enable time selection UI in the popover */
	readonly enableTime?: boolean
	/** 12 or 24 hour cycle for time selection */
	readonly hourCycle?: 12 | 24
	/** locale for displaying the date (e.g. 'fi', 'sv', 'en') */
	readonly locale?: string
	/** when true, component is read-only (no popover interaction) */
	readonly readOnly?: boolean
	/** earliest month visible in the calendar / year dropdown */
	readonly startMonth?: Date
	/** latest month visible in the calendar / year dropdown */
	readonly endMonth?: Date
	/** dates that cannot be selected (react-day-picker matcher) */
	readonly disabledDates?: Matcher | Matcher[]
	/** extra classes for the trigger button */
	readonly className?: string
	/** id for the trigger button (e.g. to pair with a `<Label htmlFor>`) */
	readonly id?: string
	/** marks the trigger as invalid (destructive border + ring) */
	readonly 'aria-invalid'?: React.AriaAttributes['aria-invalid']
	/** accessible name when there is no visible `<Label htmlFor={id}>` */
	readonly '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`.
	 */
	readonly 'aria-labelledby'?: string
	/** Ids of the help text and error message, passed on to the button */
	readonly 'aria-describedby'?: string
}

/**
 * Format a Date to a string value for form submission
 * - Without time: YYYY-MM-DD
 * - With time: YYYY-MM-DDTHH:mm (ISO 8601)
 */
function formatDateValue(date: Date | undefined, enableTime: boolean): string {
	if (!date) return ''
	return DateFns.format(date, enableTime ? "yyyy-MM-dd'T'HH:mm" : 'yyyy-MM-dd')
}

function formatDisplay(date: Date, locale: string, enableTime: boolean): string {
	const options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' }
	if (enableTime) {
		options.hour = '2-digit'
		options.minute = '2-digit'
	}
	return date.toLocaleDateString(locale, options)
}

export function DateTimePicker({
	value,
	onChange,
	placeholder,
	disabled,
	enableTime = false,
	hourCycle = 24,
	locale = 'en-GB',
	readOnly,
	startMonth,
	endMonth,
	disabledDates,
	className,
	id,
	'aria-invalid': ariaInvalid,
	'aria-label': ariaLabel,
	'aria-labelledby': ariaLabelledby,
	'aria-describedby': ariaDescribedby,
}: DateTimePickerProps) {
	const [open, setOpen] = React.useState(false)
	const [date, setDate] = React.useState<Date | undefined>(
		value ? (value.includes('T') ? new Date(value) : new Date(`${value}T00:00:00`)) : undefined,
	)
	const generatedId = React.useId()
	const buttonId = id ?? generatedId

	// keep internal state in sync with external value
	React.useEffect(() => {
		if (!value) {
			setDate(undefined)
			return
		}
		const next = value.includes('T') ? new Date(value) : new Date(`${value}T00:00:00`)
		if (!Number.isNaN(next.getTime())) setDate(next)
	}, [value])

	const hours = React.useMemo(
		() => (hourCycle === 12 ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i)),
		[hourCycle],
	)

	const buttonLabel = date ? formatDisplay(date, locale, enableTime) : placeholder

	function handleHourChange(newDate: Date, hourValue: string) {
		if (hourCycle === 12) {
			const hour = Number.parseInt(hourValue, 10)
			const currentHours = newDate.getHours()
			const isPM = currentHours >= 12
			let newHour = hour === 12 ? 0 : hour
			if (isPM) newHour += 12
			newDate.setHours(newHour)
		} else {
			newDate.setHours(Number.parseInt(hourValue, 10))
		}
	}

	function handleMinuteChange(newDate: Date, minuteValue: string) {
		newDate.setMinutes(Number.parseInt(minuteValue, 10))
	}

	function handleAmPmChange(newDate: Date, ampmValue: string) {
		const currentHours = newDate.getHours()
		const isPM = ampmValue === 'PM'
		if (isPM && currentHours < 12) {
			newDate.setHours(currentHours + 12)
		} else if (!isPM && currentHours >= 12) {
			newDate.setHours(currentHours - 12)
		}
	}

	function handleTimeChange(type: 'hour' | 'minute' | 'ampm', nextValue: string) {
		if (!date) return
		const newDate = new Date(date)

		switch (type) {
			case 'hour':
				handleHourChange(newDate, nextValue)
				break
			case 'minute':
				handleMinuteChange(newDate, nextValue)
				break
			case 'ampm':
				handleAmPmChange(newDate, nextValue)
				break
		}

		setDate(newDate)
		onChange?.(formatDateValue(newDate, enableTime))
	}

	return (
		<Popover open={open && !readOnly} onOpenChange={setOpen}>
			<PopoverTrigger
				disabled={disabled}
				render={
					<Button
						data-read-only={readOnly || undefined}
						data-empty={!date || undefined}
						aria-invalid={ariaInvalid}
						aria-disabled={disabled}
						variant="outline"
						id={buttonId}
						className={cn(
							'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,
						)}
						aria-label={ariaLabelledby ? undefined : ariaLabel}
						aria-labelledby={ariaLabelledby ? `${ariaLabelledby} ${buttonId}` : undefined}
						aria-describedby={ariaDescribedby}
					/>
				}
			>
				<span className="truncate">{buttonLabel}</span>
				<CalendarIcon 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 calendar is not announced as just "dialog"
				aria-labelledby={ariaLabelledby}
				aria-label={ariaLabelledby ? undefined : ariaLabel}
			>
				<div className="sm:flex">
					<Calendar
						mode="single"
						selected={date}
						captionLayout="dropdown"
						startMonth={startMonth}
						endMonth={endMonth}
						disabled={disabledDates}
						onSelect={(nextDate) => {
							setDate(nextDate)
							onChange?.(formatDateValue(nextDate, enableTime))
							if (!enableTime) setOpen(false)
						}}
					/>
					{enableTime && (
						<div className="flex flex-col divide-y border-t sm:h-[300px] sm:flex-row sm:divide-x sm:divide-y-0 sm:border-t-0 sm:border-l">
							<ScrollArea className="w-64 sm:w-auto">
								<div className="flex sm:flex-col p-2">
									{hours.map((hour) => {
										const isSelected =
											!!date && (hourCycle === 12 ? (date.getHours() % 12 || 12) === hour : date.getHours() === hour)
										return (
											<Button
												key={hour}
												size="icon"
												variant={isSelected ? 'default' : 'ghost'}
												className="aspect-square shrink-0 sm:w-full"
												disabled={!date}
												onClick={() => handleTimeChange('hour', hour.toString())}
											>
												{hourCycle === 24 ? hour.toString().padStart(2, '0') : hour}
											</Button>
										)
									})}
								</div>
								<ScrollBar orientation="horizontal" className="sm:hidden" />
							</ScrollArea>
							<ScrollArea className="w-64 sm:w-auto">
								<div className="flex sm:flex-col p-2">
									{Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => (
										<Button
											key={minute}
											size="icon"
											variant={date && date.getMinutes() === minute ? 'default' : 'ghost'}
											className="aspect-square shrink-0 sm:w-full"
											disabled={!date}
											onClick={() => handleTimeChange('minute', minute.toString())}
										>
											{minute.toString().padStart(2, '0')}
										</Button>
									))}
								</div>
								<ScrollBar orientation="horizontal" className="sm:hidden" />
							</ScrollArea>
							{hourCycle === 12 && (
								<ScrollArea>
									<div className="flex sm:flex-col p-2">
										{['AM', 'PM'].map((ampm) => (
											<Button
												key={ampm}
												size="icon"
												variant={
													date && ((ampm === 'AM' && date.getHours() < 12) || (ampm === 'PM' && date.getHours() >= 12))
														? 'default'
														: 'ghost'
												}
												className="aspect-square shrink-0 sm:w-full"
												disabled={!date}
												onClick={() => handleTimeChange('ampm', ampm)}
											>
												{ampm}
											</Button>
										))}
									</div>
								</ScrollArea>
							)}
						</div>
					)}
				</div>
			</PopoverContent>
		</Popover>
	)
}

A datetime picker component that combines date and time selection functionality. Built with accessibility and ease of use in mind.

Features

  • Combined date and time selection
  • Support for both 12-hour and 24-hour time formats
  • Locale-aware display via Intl.DateTimeFormat (locale prop)
  • Configurable granularity (day, hour, minute, second)
  • Internationalization support
  • Year range customization
  • Keyboard navigation support
  • Screen reader friendly
  • Built on top of React Day Picker and the Base UI Popover

Usage

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

// Date only — value is a `YYYY-MM-DD` string
const [date, setDate] = useState<string>('')

<DateTimePicker value={date} onChange={setDate} placeholder="Pick a date" />

// With time — value becomes `YYYY-MM-DDTHH:mm`
<DateTimePicker value={date} onChange={setDate} enableTime placeholder="Pick a date and time" />

// 12-hour clock, localized display
<DateTimePicker value={date} onChange={setDate} enableTime hourCycle={12} locale="en-US" />

// Constrain the selectable range
<DateTimePicker
  value={date}
  onChange={setDate}
  startMonth={new Date(2020, 0)}
  endMonth={new Date(2030, 11)}
  disabledDates={{ dayOfWeek: [0, 6] }}
/>

// Read-only / disabled
<DateTimePicker value={date} readOnly />
<DateTimePicker value={date} disabled />

Examples

Default

A date field labelled with Field and FieldLabel.

With time

Set enableTime to pick hours and minutes as well. The 24-hour clock is the default.

12-hour clock

Use hourCycle={12} for AM/PM.

Localized

The locale prop formats the displayed date. The value is the same YYYY-MM-DD string in every locale.

Constrained range

Limit the calendar with startMonth and endMonth, and block dates with disabledDates. Here only weekdays in the collection season can be picked.

Controlled

A booking form in a Card that keeps the value in state and shows a summary.

Invalid

Set aria-invalid and explain the error in a FieldError.

Disabled Component

Showing the disabled state of the picker.

Readonly Component

Shows the value without letting it change.

Accessibility

The DateTime Picker component follows WCAG 2.1 guidelines and provides:

  • Proper ARIA roles and attributes
  • Keyboard navigation support:
    • Tab: Move between inputs and calendar
    • Space/Enter: Open/close calendar, select date
    • Arrow keys: Navigate calendar dates and time inputs
    • PageUp/PageDown: Navigate months
    • Home/End: Navigate to start/end of week
    • Esc: Close calendar
  • Focus management
  • Screen reader announcements for selections
  • High contrast visual indicators
  • Clear focus states

Best Practices

  1. Date and Time Selection

    • Use appropriate default values
    • Consider time zone handling
    • Validate date ranges if needed
    • Show clear format examples
  2. Visual Design

    • Maintain consistent sizing
    • Provide clear selection feedback
    • Consider mobile touch targets
    • Use appropriate spacing
  3. Interaction

    • Handle loading states
    • Provide clear validation feedback
    • Consider keyboard shortcuts
    • Test with different input methods
  4. Accessibility

    • Write descriptive aria-labels
    • Test with screen readers
    • Verify keyboard navigation
    • Ensure sufficient color contrast
  • Calendar
  • DatePicker
  • DateRangePicker
  • TimePicker