Skip to contentVibraUI
Inputs & filters

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-picker

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valueDate—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.
placeholderstring"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.
disabledbooleanfalseDims the trigger and stops it opening.
minDate—Earliest selectable day; earlier months cannot be reached.
maxDate—Latest selectable day; later months cannot be reached.
formatstring"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-labelstring—Names the trigger, e.g. Invoice date.
calendarPickerCalendarProps—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.
defaultOpenbooleanfalseOpens 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".
timeZonestring"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.
classNamestring—Merged onto the trigger, which is the root; the remaining button props are spread onto it too.

Dependencies

Source

components/ui/date-picker.tsx
"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 }