Date picker
A date field that opens a calendar, with optional shortcuts down the side.
The trigger label comes from value alone, read on the clock of timeZone (UTC unless told otherwise) through date-fns format, so the server and the client always write the same string — nothing here reads the clock, or the runtime's own zone, while rendering. The calendar counts its days in the same zone and a pick comes back as that day's midnight there, so a day stored as UTC midnight round-trips without moving, east of Greenwich or west of it. A preset whose date is a function is called on the press, which is where "today" belongs. Controlled by the presence of the value key, not by its contents: undefined is a date's own "nothing chosen", so value={undefined} stays controlled and a caller clearing a controlled picker never has it quietly take over its own state; leave the key out entirely for an uncontrolled picker. Opening puts focus on the calendar's own tab stop rather than the previous-month arrow. min and max both disable the days outside them and stop the month navigation there. Presets are a wrapping row above the calendar on a phone and a column beside it from sm up.
Install
npx shadcn@latest add @vibra/date-pickerNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { DatePicker } from "@/components/ui/date-picker"
/** Today as a UTC day, the zone the picker counts in, read on the press. */
function today(): Date {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
}
/** The first day of this UTC month, read on the press. */
function monthStart(): Date {
const now = new Date()
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
}
export default function DatePickerDemo() {
const [invoiceDate, setInvoiceDate] = React.useState<Date | undefined>(new Date(Date.UTC(2026, 8, 4)))
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<span className="text-sm font-medium">Invoice date</span>
<DatePicker
aria-label="Invoice date"
value={invoiceDate}
onValueChange={setInvoiceDate}
max={new Date(Date.UTC(2026, 11, 31))}
presets={[
// Functions, so the clock is read on the press and never in render.
{ label: "Today", date: today },
{ label: "Month start", date: monthStart },
]}
/>
<p className="text-xs text-muted-foreground">Payment terms run 30 days from this date.</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | Date | — | The chosen date; the trigger label is derived from it alone. Passing the key at all — even as undefined — makes the picker controlled. |
| onValueChange | (date: Date | undefined) => void | — | Called with the new date, or undefined when the chosen day is pressed again. |
| placeholder | string | "Pick a date" | Stands in for the label while no date is chosen. |
| presets | { label: string; date: Date | (() => Date) }[] | — | Shortcuts down the left; a function is called on the press, never in render. |
| disabled | boolean | false | Dims the trigger and stops it opening. |
| min | Date | — | Earliest selectable day; earlier months cannot be reached. |
| max | Date | — | Latest selectable day; later months cannot be reached. |
| format | string | "PPP" | A date-fns format string; the default reads "September 4th, 2026". |
| size | "sm" | "default" | "default" | sm drops the trigger to h-7 for dense forms. |
| align | "start" | "end" | "start" | Which edge the calendar lines up with. |
| aria-label | string | — | Names the trigger, e.g. Invoice date. |
| calendar | PickerCalendarProps | — | Handed to the calendar in the popover — today (so a page pinned to a date of its own marks that day, not the clock's), labels (to say why a day is unavailable), modifiers, footer, weekStartsOn. A disabled given here is added to min and max; the mode, the selection, the months, the focus and the time zone stay the picker's. |
| defaultOpen | boolean | false | Opens the calendar on the first render, for a preview. The panel is a dialog named by the trigger's aria-label, or "Choose a date". |
| timeZone | string | "UTC" | The zone days are counted in: the day the trigger reads, the calendar's days, and the instant a pick hands back — that day's midnight there. UTC, as every date in the kit is pinned; pass the reader's own zone for local days. |
| className | string | — | Merged onto the trigger, which is the root; the remaining button props are spread onto it too. |
Dependencies
Source
"use client"
import * as React from "react"
import { format as formatWith } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar, pickerDisabled, type PickerCalendarProps } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
export type DatePickerPreset = {
label: string
/** A function is called when the preset is pressed, never while rendering. */
date: Date | (() => Date)
}
// The trigger button is the root: className and the rest of the props land on
// it. The names below are re-declared because the item owns them — className is
// narrowed back to a string, and size is the item's density axis.
export type DatePickerProps = Omit<
React.ComponentProps<typeof Button>,
"value" | "className" | "size" | "variant" | "disabled" | "aria-label" | "children" | "render"
> & {
value?: Date
onValueChange?: (date: Date | undefined) => void
placeholder?: string
presets?: DatePickerPreset[]
disabled?: boolean
min?: Date
max?: Date
/** A date-fns format string; the default reads "September 4th, 2026". */
format?: string
size?: "sm" | "default"
className?: string
align?: "start" | "end"
/** Names the trigger, e.g. "Invoice date". */
"aria-label"?: string
/**
* Handed to the calendar in the popover: `today` to mark a day other than
* the clock's, `labels` to say why a day is unavailable, `modifiers` to mark
* days, `footer` for a line under the grid. `disabled` adds to min and max.
* Its days are counted in `timeZone`, as the picker's own are.
*/
calendar?: PickerCalendarProps
/** Opens the calendar on the first render — for a preview; a reader opens it with the trigger. */
defaultOpen?: boolean
/**
* The zone days are counted in: the day the trigger reads, the calendar's
* days, and the instant a pick hands back — midnight of that day there.
* "UTC" by default, as every date in the kit is pinned.
*/
timeZone?: string
}
/**
* `date` as a clock in `timeZone` reads it, as a Date whose own fields hold
* those numbers: a date-fns format string then writes that zone's day, and the
* server and the browser write the same words whatever zone each runs in.
*/
function onClockIn(date: Date, timeZone: string): Date {
const fields: Record<string, number> = {}
const clock = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
})
for (const part of clock.formatToParts(date)) if (part.type !== "literal") fields[part.type] = Number(part.value)
return new Date(fields.year, fields.month - 1, fields.day, fields.hour, fields.minute, fields.second)
}
/** A date field that opens a calendar, with optional shortcuts down the side. */
function DatePicker(props: DatePickerProps) {
const {
value,
onValueChange,
placeholder = "Pick a date",
presets,
disabled = false,
min,
max,
format = "PPP",
size = "default",
className,
align = "start",
"aria-label": ariaLabel,
calendar,
defaultOpen = false,
timeZone = "UTC",
...rest
} = props
const { disabled: moreDisabled, ...calendarProps } = calendar ?? {}
const panelRef = React.useRef<HTMLDivElement>(null)
const [open, setOpen] = React.useState(defaultOpen)
// The *key*, not its value, says who holds the date: `undefined` is a date's
// own "nothing chosen", so `value={undefined}` has to stay controlled rather
// than silently handing the picker its own state.
const controlled = "value" in props
const [internal, setInternal] = React.useState<Date | undefined>(value)
const selected = controlled ? value : internal
function commit(next: Date | undefined) {
if (!controlled) setInternal(next)
onValueChange?.(next)
setOpen(false)
}
const outOfRange = pickerDisabled(min, max, moreDisabled)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
type="button"
data-slot="date-picker"
data-size={size}
data-align={align}
variant="outline"
size={size === "sm" ? "sm" : "default"}
disabled={disabled}
aria-label={ariaLabel}
className={cn("w-full justify-start gap-2 font-normal", className)}
{...rest}
>
<CalendarIcon className="text-muted-foreground" />
<span className={cn("truncate", !selected && "text-muted-foreground")}>
{/* Derived from the value and the zone alone — never from today
or the runtime's own zone — so the server and the client
always write the same string. */}
{selected ? formatWith(onClockIn(selected, timeZone), format) : placeholder}
</span>
</Button>
}
/>
{/* COUPLED TO registry/vibra/ui/popover.tsx: a calendar brings its own
width and padding, so the panel's w-72 and p-2.5 come off. */}
<PopoverContent
ref={panelRef}
align={align}
// The panel is a dialog, and a dialog needs a name: the trigger's own
// aria-label when it has one, else what the panel is for.
aria-label={ariaLabel ?? "Choose a date"}
// Base UI would otherwise focus the first tabbable, which is the
// previous-month arrow; the calendar's own tab stop is the day
// react-day-picker marks as the focus target, and landing there is what
// makes the arrow keys work straight away.
initialFocus={() =>
panelRef.current?.querySelector<HTMLElement>('button[data-day][tabindex="0"]') ?? true
}
// Never wider than the room the positioner found: on a phone the
// preset row would otherwise size the panel to the whole viewport and
// the collision padding would push it past the edge.
className="w-auto max-w-(--available-width) flex-col gap-0 p-0 sm:flex-row"
>
{presets && presets.length > 0 ? (
<div
data-slot="date-picker-presets"
// A wrapping row above the calendar on a phone, a column beside it
// from sm up — the shortcuts are the fast path and stay reachable.
className="flex shrink-0 flex-row flex-wrap gap-0.5 border-b border-border p-2 sm:w-32 sm:flex-col sm:border-e sm:border-b-0"
>
{presets.map((preset) => (
<Button
key={preset.label}
type="button"
variant="ghost"
size="sm"
className="justify-start font-normal"
onClick={() =>
commit(typeof preset.date === "function" ? preset.date() : preset.date)
}
>
{preset.label}
</Button>
))}
</div>
) : null}
<Calendar
{...calendarProps}
mode="single"
autoFocus
// The calendar counts its days in the same zone and hands back that
// day's midnight there, as a plain Date.
timeZone={timeZone}
selected={selected}
onSelect={(date) => commit(date ? new Date(date.getTime()) : undefined)}
defaultMonth={selected}
startMonth={min}
endMonth={max}
disabled={outOfRange}
/>
</PopoverContent>
</Popover>
)
}
export { DatePicker }