a day or a range of days chosen from a field that opens the calendar
npx shadcn add @ag/date-pickerAlready installed? Preview an update
npx shadcn add @ag/date-picker --dry-runYour 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 afieldit is the field's control (named by the label with the choice, described, red when wrong, not opening while read-only). Plain or glass;containerfor a layer that covers the page.
also called datepicker
shelf controls
uses calendar, field-context, material, popover, select
used by components
the demo above, verbatim
"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
"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>
)
}