← ui

date-picker

ui

a day or a range of days chosen from a field that opens the calendar

npx shadcn add @ag/date-picker
Already installed? Preview an update
npx shadcn add @ag/date-picker --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: a day, or a range of days, chosen from a field: the select's field face at its three heights, showing the choice in the locale's words (format), opening the calendar in a popover. The keyboard lands on the chosen day or today, Enter chooses and the field takes the focus back; a range closes once both ends are chosen. Inside a field it is the field's control (named by the label with the choice, described, red when wrong, not opening while read-only). Plain or glass; container for a layer that covers the page.
Updated 2026-10-11 · release notes

also called datepicker

shelf controls

uses calendar, field-context, material, popover, select

used by components

the demo above, verbatim

date-picker.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  type DateRange,
  type Day,
  weekdayOf,
} from "@/registry/base-nova/lib/calendar-core"
import { Button } from "@/registry/base-nova/ui/button"
import { useToday } from "@/registry/base-nova/ui/calendar"
import { DatePicker } from "@/registry/base-nova/ui/date-picker"
import { Field } from "@/registry/base-nova/ui/field"
import { Input } from "@/registry/base-nova/ui/input"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Switch } from "@/registry/base-nova/ui/switch"

// #region delivery
/** A field that is a day: named by its label, described by its line, weekdays only,
 *  and held still while the form sends. */
function Delivery({ sending = false }: { sending?: boolean }) {
  const today = useToday()
  const [day, setDay] = React.useState<Day | null>(null)
  return (
    <Field
      label="delivery"
      description="the day the cut is handed over, a weekday"
      required
      readOnly={sending}
    >
      <DatePicker
        value={day}
        onValueChange={setDay}
        min={today ?? undefined}
        disabled={(d) => weekdayOf(d) === 0 || weekdayOf(d) === 6}
        className="w-56"
      />
    </Field>
  )
}
// #endregion

// #region trip
function Trip() {
  const [trip, setTrip] = React.useState<DateRange | null>(null)
  return (
    <DatePicker
      mode="range"
      aria-label="trip"
      value={trip}
      onValueChange={setTrip}
      format={{ month: "short", day: "numeric" }}
      name="trip"
      className="w-56"
    />
  )
}
// #endregion

export default function DatePickerDemo() {
  const [sending, setSending] = React.useState(false)
  return (
    <div className="space-y-8">
      <Sample
        name="materials"
        title="plain and glass, in a field"
        description="the keyboard lands on the chosen day, or today; enter chooses it and the field takes the focus back."
        with="delivery"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="flex flex-col gap-4 p-6">
            <Delivery sending={sending} />
            <Switch checked={sending} onCheckedChange={setSending}>
              read-only (a form sending)
            </Switch>
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/field.jpg)] bg-cover bg-center p-6 text-white">
              <Delivery />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample
        name="range"
        glance="wide"
        title="a range of days"
        description="it closes once both ends are chosen; a form posts it as an ISO interval, 2026-10-03/2026-10-09."
        with="trip"
      >
        <div className="grid gap-4 sm:grid-cols-2">
          <div className="p-6">
            <Trip />
          </div>
          <MaterialProvider material="glass">
            <div className="rounded-xl bg-[url(/lightbox/river.jpg)] bg-cover bg-center p-6 text-white">
              <Trip />
            </div>
          </MaterialProvider>
        </div>
      </Sample>
      <Sample name="sizes" title="the input's heights, wrong">
        <div className="flex flex-col gap-3">
          {(["sm", "md", "lg"] as const).map((size) => (
            <div key={size} className="flex flex-wrap items-center gap-2">
              <Input size={size} placeholder="title" aria-label="title" />
              <DatePicker size={size} aria-label="day" />
              <Button size={size} variant="secondary">
                save
              </Button>
            </div>
          ))}
          <DatePicker aria-label="day" aria-invalid className="w-fit" />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

date-picker.tsx
"use client"

import { CalendarDays } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type DateRange,
  type Day,
  formatDay,
  formatRange,
  type Weekday,
} from "@/registry/base-nova/lib/calendar-core"
import {
  Calendar,
  type CalendarLabels,
  useCalendarLocale,
} from "@/registry/base-nova/ui/calendar"
import { FieldContext, useField } from "@/registry/base-nova/ui/field-context"
import {
  type Material,
  type Scheme,
  useMaterial,
  useSurfaceScheme,
} from "@/registry/base-nova/ui/material"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/base-nova/ui/popover"
import {
  type SelectSize,
  selectFieldFace,
} from "@/registry/base-nova/ui/select"

// A DAY, OR A RANGE OF DAYS, CHOSEN FROM A FIELD: the select's field (the input's well,
// or a pill of glass, at its heights, so it sits in a form's row as any field does)
// showing the choice in the locale's words, which opens the calendar in a popover. The
// keyboard lands on the chosen day (or today), the calendar's keys move it, Enter
// chooses and the popover closes back onto the field; a range closes once both ends
// are chosen. Escape closes it unchanged.
//
// Inside a field it is the field's control: the label names it (with the choice, so a
// screen reader hears "due, Oct 11, 2026"), the description and the error describe it,
// red when wrong, and read-only it does not open (a form sending). The value is the
// calendar's, a day "2026-10-11" or a range `{ from, to }` (lib/calendar-core), and a
// form posts it by `name` as the day or an ISO interval.

/** Shown when nothing is chosen, by mode. */
const PLACEHOLDER = { single: "pick a day", range: "pick the days" }

type One = {
  mode?: "single"
  value?: Day | null
  defaultValue?: Day | null
  onValueChange?: (day: Day) => void
}
type Range = {
  mode: "range"
  value?: DateRange | null
  defaultValue?: DateRange | null
  onValueChange?: (range: DateRange) => void
}

export type DatePickerProps = (One | Range) & {
  /** Shown while nothing is chosen. */
  placeholder?: React.ReactNode
  /** How the choice is said (`Intl.DateTimeFormat` options). Default: medium, "Oct
   *  11, 2026". */
  format?: Intl.DateTimeFormatOptions
  min?: Day
  max?: Day
  /** True: it does not open. A function: the days the calendar says no to. */
  disabled?: boolean | ((day: Day) => boolean)
  /** Shown, and nothing changes it (a form sending): it does not open. */
  readOnly?: boolean
  locale?: string
  weekStart?: Weekday
  /** A day in the month the calendar opens on while nothing is chosen. */
  defaultMonth?: Day
  today?: Day
  /** Submitted with a form: the day, or the range as an ISO interval. */
  name?: string
  labels?: Partial<CalendarLabels>
  id?: string
  /** Its name, where no field's label names it. */
  "aria-label"?: string
  "aria-invalid"?: boolean
  "aria-describedby"?: string
  size?: SelectSize
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's.
   *  Its calendar, portalled out of the page, stands in it too. */
  scheme?: Scheme
  /** Where its calendar is drawn. Default: the nearest `PortalTarget` (portal), else
   *  the body. */
  container?: React.ComponentProps<typeof PopoverContent>["container"]
  className?: string
}

export function DatePicker(props: DatePickerProps) {
  const {
    placeholder,
    format,
    min,
    max,
    disabled,
    readOnly,
    locale: asked,
    weekStart,
    defaultMonth,
    today,
    name,
    labels,
    id,
    "aria-label": label,
    "aria-invalid": invalid,
    "aria-describedby": describedBy,
    size = "md",
    material,
    scheme,
    container,
    className,
  } = props
  const m = useMaterial(material)
  // The field is the surface: under `backdrop`, read by the picture under it.
  const [s, here] = useSurfaceScheme<HTMLButtonElement>(scheme)
  const field = useField()
  const locale = useCalendarLocale(asked)
  const range = props.mode === "range"
  const made = React.useId()
  const nameId = `${made}-name`
  const valueId = `${made}-value`
  const popup = React.useRef<HTMLDivElement>(null)
  const [open, setOpen] = React.useState(false)
  const off = disabled === true || field?.wiring.disabled === true
  const still = readOnly === true || field?.readOnly === true

  // The value, the consumer's or held here.
  const [held, setHeld] = React.useState<Day | DateRange | null>(
    props.defaultValue ?? null,
  )
  const value = props.value !== undefined ? props.value : held
  const choose = (next: Day | DateRange) => {
    setHeld(next)
    ;(props.onValueChange as ((v: Day | DateRange) => void) | undefined)?.(next)
    setOpen(false)
  }

  const said =
    value === null
      ? null
      : typeof value === "string"
        ? formatDay(value, locale, format)
        : formatRange(value, locale, format)
  const posted =
    value === null
      ? ""
      : typeof value === "string"
        ? value
        : `${value.from}/${value.to}`

  // The calendar between the two modes, the value handed on as it is.
  const calendar = {
    min,
    max,
    disabled: typeof disabled === "function" ? disabled : undefined,
    locale,
    weekStart,
    defaultMonth,
    today,
    labels,
  }
  return (
    <Popover
      open={open}
      onOpenChange={(next) => setOpen(next && (off || still) ? false : next)}
    >
      <PopoverTrigger
        ref={here}
        id={id ?? field?.wiring.id}
        disabled={off}
        data-slot="date-picker"
        data-size={size}
        data-material={m}
        // Named by the label and the choice together: a label alone would replace
        // the words the field shows.
        aria-labelledby={`${field?.labelId ?? (label ? nameId : "")} ${valueId}`.trim()}
        aria-invalid={invalid || field?.invalid || undefined}
        aria-describedby={describedBy ?? field?.wiring["aria-describedby"]}
        aria-required={field?.wiring["aria-required"]}
        data-readonly={still || undefined}
        className={cn(
          selectFieldFace(m, size),
          "min-w-40 disabled:cursor-not-allowed disabled:opacity-50",
          schemeOf(s),
          className,
        )}
      >
        {label && !field?.labelId && (
          <span id={nameId} hidden>
            {label}
          </span>
        )}
        <span
          id={valueId}
          data-slot="date-picker-value"
          data-placeholder={said === null || undefined}
          className={cn(
            "min-w-0 truncate data-placeholder:opacity-45",
            m === "plain" && "text-foreground",
          )}
        >
          {said ?? placeholder ?? PLACEHOLDER[range ? "range" : "single"]}
        </span>
        <CalendarDays aria-hidden className="shrink-0 opacity-50" />
      </PopoverTrigger>
      <PopoverContent
        ref={popup}
        align="start"
        material={m}
        scheme={s}
        container={container}
        aria-label={range ? "choose the days" : "choose a day"}
        // The keyboard lands on the calendar's own day (the chosen one, or today),
        // not on the month's arrows before it.
        initialFocus={() =>
          popup.current?.querySelector<HTMLElement>(
            '[data-slot=calendar-day][tabindex="0"]',
          ) ?? true
        }
        className="w-auto p-3"
      >
        {/* The field is the trigger's: the calendar inside is no second control of
            it (its id, its label, its read-only). */}
        <FieldContext.Provider value={null}>
          {range ? (
            <Calendar
              mode="range"
              value={value as DateRange | null}
              onValueChange={choose}
              {...calendar}
            />
          ) : (
            <Calendar
              value={value as Day | null}
              onValueChange={choose}
              {...calendar}
            />
          )}
        </FieldContext.Provider>
      </PopoverContent>
      {name && <input type="hidden" name={name} value={posted} />}
    </Popover>
  )
}