Date time picker
A day and a time in one popover, alone or as a range. Copy it into your project.
- value
- {"moment":"2026-09-28T14:05","range":["",""]}
The picker the filter uses for datetime fields, as a component of its own: a calendar next to the hour and minute columns of the time picker.
- Picking a day keeps the time (
00:00if there's none yet). Picking a time before a day uses today. - Now picks the current moment, OK closes.
- The field shows the day in the user's locale and the time on a 24-hour clock:
09/28/2026 14:05in the US,28/09/2026 14:05in Vietnam.
Installation
Add the shadcn/ui components it uses:
pnpm dlx shadcn@latest add button popover calendarCopy the time picker into components/ui/time-picker.tsx: the date time picker is built from its parts.
Copy the code into components/ui/date-time-picker.tsx, next to it:
"use client"
import * as React from "react"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Calendar } from "@/components/ui/calendar"
import {
currentTime,
defaultTimePickerLabels,
PickerField,
PickerFooter,
RangePickerField,
TimeColumns,
type PickerPanel,
type TimePickerLabels,
} from "./time-picker"
const pad = (part: number) => String(part).padStart(2, "0")
/** A date's day in local time, as `YYYY-MM-DD`. */
const toDay = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
/** The browser's local date and time as `YYYY-MM-DDTHH:mm`. */
export const currentDateTime = () => `${toDay(new Date())}T${currentTime()}`
const ZONED = /T.*(?:Z|[+-]\d{2}:?\d{2})$/i
const LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/
/** The picked day (as a local `Date` for the calendar) and `HH:mm`; a zoned value is read in local time. */
function splitDateTime(value: string) {
const zoned = ZONED.test(value) ? new Date(value) : null
const local =
zoned && !Number.isNaN(zoned.getTime())
? `${toDay(zoned)}T${pad(zoned.getHours())}:${pad(zoned.getMinutes())}`
: value
const match = LOCAL.exec(local)
if (!match) return {}
const [year, month, day] = match.slice(1, 4).map(Number) as [
number,
number,
number,
]
return {
date: new Date(year, month - 1, day),
day: `${match[1]}-${match[2]}-${match[3]}`,
time: match[4],
}
}
const subscribe = () => () => {}
/** `false` on the server and while hydrating: the locale and time zone are only known in the browser. */
function useHydrated() {
return React.useSyncExternalStore(
subscribe,
() => true,
() => false
)
}
/** E.g. "09/28/2026 14:05": the day in the user's locale, the time on a 24-hour clock. */
function useDateTimeFormat() {
const hydrated = useHydrated()
return (value: string) => {
const { date, time } = splitDateTime(value)
if (!hydrated || !date || !time) return value.replace("T", " ")
const day = date.toLocaleDateString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
})
return `${day} ${time}`
}
}
function dateTimePanel(labels: TimePickerLabels): PickerPanel {
return function renderDateTimePanel(value, onValueChange, onOk) {
const { date, day, time } = splitDateTime(value)
return (
<>
<div className="flex">
<Calendar
mode="single"
// Clicking the picked day again keeps it instead of clearing it.
required
defaultMonth={date}
selected={date}
onSelect={(next: Date) =>
onValueChange(`${toDay(next)}T${time ?? "00:00"}`)
}
/>
{/* As tall as the calendar, whatever the month. */}
<div className="relative w-28 border-s">
<TimeColumns
className="absolute inset-0 h-auto"
value={time ?? ""}
onValueChange={(next) =>
onValueChange(`${day ?? toDay(new Date())}T${next}`)
}
onConfirm={onOk}
labels={labels}
/>
</div>
</div>
<PickerFooter
labels={labels}
okDisabled={!day}
onOk={onOk}
onNow={() => {
onValueChange(currentDateTime())
onOk()
}}
/>
</>
)
}
}
export interface DateTimePickerProps {
/** Local date and time as `YYYY-MM-DDTHH:mm`, or `""`. A zoned value (`…Z`) shows in local time. */
value: string
onValueChange: (value: string) => void
placeholder?: string
labels?: Partial<TimePickerLabels>
id?: string
"aria-label"?: string
className?: string
}
/** A moment, picked from a calendar and hour and minute columns. */
export function DateTimePicker({
placeholder = "Pick date and time",
labels,
className,
...props
}: DateTimePickerProps) {
const all = { ...defaultTimePickerLabels, ...labels }
return (
<PickerField
{...props}
placeholder={placeholder}
format={useDateTimeFormat()}
icon={CalendarIcon}
panel={dateTimePanel(all)}
className={cn("w-52", className)}
/>
)
}
export interface DateTimeRangePickerProps extends Omit<
DateTimePickerProps,
"value" | "onValueChange" | "placeholder"
> {
/** `[from, to]`, each `YYYY-MM-DDTHH:mm` or `""`. */
value: [string, string]
onValueChange: (value: [string, string]) => void
}
/** A start and an end moment in one field. */
export function DateTimeRangePicker({
labels,
...props
}: DateTimeRangePickerProps) {
const all = { ...defaultTimePickerLabels, ...labels }
return (
<RangePickerField
{...props}
labels={all}
format={useDateTimeFormat()}
icon={CalendarIcon}
panel={dateTimePanel(all)}
sideClassName="min-w-11"
/>
)
}
Usage
The value is the local date and time without a zone, "2026-09-28T14:05", or "":
import { DateTimePicker } from "@/components/ui/date-time-picker"
const [startsAt, setStartsAt] = React.useState("")
<DateTimePicker aria-label="Starts at" value={startsAt} onValueChange={setStartsAt} />import { DateTimeRangePicker } from "@/components/ui/date-time-picker"
const [created, setCreated] = React.useState<[string, string]>(["", ""])
<DateTimeRangePicker aria-label="Created" value={created} onValueChange={setCreated} />Time zones
The picker works in the user's local time and returns it without a zone, like <input type="datetime-local">. To store an instant, convert it: new Date("2026-09-28T14:05").toISOString() reads the value in the browser's time zone.
A zoned value ("2026-09-28T07:05:00Z") is accepted and shows in local time. The next pick returns a local value.
The field only formats the value once the page has hydrated, since the locale and time zone are only known in the browser. Before that it shows 2026-09-28 14:05.
Translate
The same labels as the time picker, plus placeholder:
<DateTimePicker
value={startsAt}
onValueChange={setStartsAt}
placeholder="Chọn ngày giờ"
labels={{ hour: "Giờ", minute: "Phút", now: "Bây giờ", from: "Từ", to: "Đến" }}
/>The calendar's month and day names follow the locale of your Calendar component.
Props
DateTimePicker
Prop
Type
DateTimeRangePicker
The same props, except placeholder, and:
Prop
Type