Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/datetime-picker.jsonInstalls 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.1Add the components it builds on: button, calendar, popover, scroll-area.
Copy the source into your project:
'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(localeprop) - 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 calendarSpace/Enter: Open/close calendar, select dateArrow keys: Navigate calendar dates and time inputsPageUp/PageDown: Navigate monthsHome/End: Navigate to start/end of weekEsc: Close calendar
- Focus management
- Screen reader announcements for selections
- High contrast visual indicators
- Clear focus states
Best Practices
-
Date and Time Selection
- Use appropriate default values
- Consider time zone handling
- Validate date ranges if needed
- Show clear format examples
-
Visual Design
- Maintain consistent sizing
- Provide clear selection feedback
- Consider mobile touch targets
- Use appropriate spacing
-
Interaction
- Handle loading states
- Provide clear validation feedback
- Consider keyboard shortcuts
- Test with different input methods
-
Accessibility
- Write descriptive aria-labels
- Test with screen readers
- Verify keyboard navigation
- Ensure sufficient color contrast
Related Components
- Calendar
- DatePicker
- DateRangePicker
- TimePicker