shadcn ui : Calendar + DatePicker Syntax
The shadcn Calendar is a thin wrapper over react-day-picker v9 (DayPicker re-exported as Calendar). The DatePicker is NOT a primitive : it is a documented composition of Popover + Button (as PopoverTrigger asChild) + Calendar (inside PopoverContent). Every other "date input" pattern (preset shortcuts, range pickers, date+time hybrids) derives from this recipe.
The single most common failure on this primitive is the silent v8 -> v9 break : the API was redesigned mid-2025 (issue #4366, 97 reactions). Old Calendar source files that pre-date shadcn v4.x will silently misbehave on react-day-picker@9.0.0. Recovery is shadcn add calendar --overwrite. See Companion : shadcn-errors-react-day-picker-v9 for the full migration playbook.
Quick Reference
Canonical Calendar call
"use client"
import * as React from "react"
import { Calendar } from "@/components/ui/calendar"
export default function CalendarDemo() {
const [date, setDate] = React.useState<Date | undefined>(new Date())
return (
<Calendar
mode="single"
selected={date}
className="rounded-md border shadow-sm"
captionLayout="dropdown"
/>
)
}
mode is mandatory in v9 : without it, TypeScript narrows selected to undefined and no day is ever marked selected. "use client" is non-optional : Calendar calls Intl.DateTimeFormat() internally and will cause a Next.js App Router hydration mismatch if rendered as an RSC.
Mode decision table
| User intent | mode |
selected type |
onSelect signature |
|---|---|---|---|
| Pick exactly one day | "single" |
Date | undefined |
(date: Date | undefined) => void |
| Pick any number of days | "multiple" |
Date[] | undefined |
(dates: Date[] | undefined) => void |
| Pick a start + end range | "range" |
DateRange | undefined |
(range: DateRange | undefined) => void |
DateRange is { from: Date \| undefined; to?: Date \| undefined }. ALWAYS read from before to ; while the user is mid-drag, to is undefined. NEVER assume both ends are present.
If required: true is also passed, the corresponding undefined branch is removed from the type (single becomes Date, multiple becomes Date[], range becomes DateRange). NEVER mix required: true with useState<Date | undefined>() ; the setter will type-error.
Four invariants
- ALWAYS pass
modeexplicitly. NEVER rely on a "default" mode ; v9 has no default and TypeScript will narrowselectedtoundefinedsilently ifmodeis omitted. - ALWAYS prefix the file with
"use client". NEVER mount Calendar in a Server Component ;Intl.DateTimeFormat()runs in the renderer and causes a hydration mismatch. - ALWAYS type the state to match the mode (
useState<Date | undefined>()for single,useState<Date[] | undefined>()for multiple,useState<DateRange | undefined>()for range). NEVER reuse aDatesetter formode="range". - ALWAYS treat the DatePicker as a recipe, NOT a component. NEVER write
import { DatePicker } from "@/components/ui/date-picker"; there is no such export. ComposePopover+Button(asChildonPopoverTrigger) +Calendar.
Decision Tree 1 : Which mode?
Q1. Does the user pick a single calendar day (birthday, deadline,
appointment)?
yes -> mode="single" + useState<Date | undefined>()
no -> Q2
Q2. Does the user pick zero or more days, with no implied order
(multi-day absence form, "available days" booking)?
yes -> mode="multiple" + useState<Date[] | undefined>()
no -> Q3
Q3. Does the user pick a continuous span from a start day to an end
day (holiday request, report period filter, hotel booking)?
yes -> mode="range" + useState<DateRange | undefined>()
+ ALWAYS render Calendar with numberOfMonths={2} for the
two-month side-by-side range UX
no -> reconsider : single, multiple, or range covers every
react-day-picker v9 selection. There is no other mode.
Decision Tree 2 : Calendar standalone or DatePicker recipe?
Q1. Should the calendar be visible at all times (admin grid, booking
page main surface, dashboard calendar)?
yes -> render <Calendar /> directly ; do NOT wrap in Popover
no -> Q2
Q2. Should the calendar appear only when the user opens an input
field or a button (form field, filter trigger, header date
picker)?
yes -> DatePicker recipe : Popover + Button (asChild on
PopoverTrigger) + Calendar inside PopoverContent ;
format the selected date in the Button label via
date-fns format()
no -> reconsider : either the calendar is always visible (Q1)
or it is gated by a trigger (Q2). There is no third
presentation.
Decision Tree 3 : Style override : className vs classNames vs modifiers?
Q1. Are you styling the OUTER calendar wrapper (border, padding,
background)?
yes -> className="..." on <Calendar /> ; merges via cn() in
the shadcn wrapper
no -> Q2
Q2. Are you restyling a structural slot of the day grid (the row of
weekday labels, the previous/next nav buttons, the day cell
itself, the dropdown caption)?
yes -> classNames={{ slot_key: "tailwind classes" }} ; valid
keys come from getDefaultClassNames() and include root,
months, month, nav, button_previous, button_next,
month_caption, dropdowns, dropdown_root, caption_label,
weekdays, weekday, week, day, range_start, range_middle,
range_end, today, outside, disabled, hidden
no -> Q3
Q3. Are you flagging SOME days as semantically special (holidays,
booked, available, today's-deadline) and want to style THOSE
specifically?
yes -> modifiers={{ holiday: holidayDates }} +
modifiersClassNames={{ holiday: "bg-red-100" }} ; the
wrapper auto-applies the className whenever a day
matches the modifier predicate
no -> Q4
Q4. Are you replacing the rendered DOM for a slot (custom day
button, custom chevron icon, custom dropdown)?
yes -> components={{ DayButton: MyDayButton, Chevron: MyIcon }}
no -> reconsider : every Calendar customisation maps to one
of className / classNames / modifiers / components.
NEVER reach into Calendar.tsx with patches.
Patterns
Pattern 1 : Single-date Calendar (standalone)
"use client"
import * as React from "react"
import { Calendar } from "@/components/ui/calendar"
export default function SingleCalendar() {
const [date, setDate] = React.useState<Date | undefined>(new Date())
return (
<Calendar
mode="single"
selected={date}
className="rounded-md border shadow-sm"
/>
)
}
ALWAYS type the state as Date | undefined ; the onSelect callback receives undefined when the user clicks the currently-selected day a second time (deselect).
Pattern 2 : Range Calendar with two-month side-by-side
"use client"
import * as React from "react"
import { type DateRange } from "react-day-picker"
import { Calendar } from "@/components/ui/calendar"
export default function RangeCalendar() {
const [range, setRange] = React.useState<DateRange | undefined>()
return (
<Calendar
mode="range"
selected={range}
numberOfMonths={2}
defaultMonth={range?.from}
/>
)
}
DateRange is imported from react-day-picker, NOT from @/components/ui/calendar ; the shadcn wrapper does not re-export it. numberOfMonths={2} is the canonical range UX.
Pattern 3 : DatePicker recipe (Popover + Button + Calendar)
"use client"
import * as React from "react"
import { format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export function DatePicker() {
const [date, setDate] = React.useState<Date | undefined>()
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-[240px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon />
{date ? format(date, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={date} />
</PopoverContent>
</Popover>
)
}
ALWAYS use asChild on PopoverTrigger so the Button (not a wrapper <div>) becomes the accessible trigger. NEVER nest a Button inside a Button. ALWAYS render the selected date via format(date, "PPP") (date-fns localised long form) ; raw date.toString() leaks the GMT offset into the label.
Pattern 4 : DatePicker range recipe with two-month popover
"use client"
import * as React from "react"
import { addDays, format } from "date-fns"
import { CalendarIcon } from "lucide-react"
import { type DateRange } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
export function DatePickerRange() {
const [date, setDate] = React.useState<DateRange | undefined>({
from: new Date(),
to: addDays(new Date(), 7),
})
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon />
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} -{" "}
{format(date.to, "LLL dd, y")}
</>
) : (
format(date.from, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="range"
defaultMonth={date?.from}
selected={date}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
)
}
ALWAYS branch on date?.from first, THEN on date.to, because mid-drag the user has chosen the start but not yet the end. NEVER assume both ends are populated when rendering the label.
Pattern 5 : Disabled dates (past, weekends, custom predicate)
import { isBefore, startOfToday, isWeekend } from "date-fns"
// Disable everything before today (no past dates)
<Calendar
mode="single"
selected={date}
disabled={(d) => isBefore(d, startOfToday())}
/>
// Disable weekends
<Calendar
mode="single"
selected={date}
disabled={isWeekend}
/>
// Disable a fixed array
<Calendar
mode="single"
selected={date}
disabled={[new Date(2026, 11, 25), new Date(2026, 0, 1)]}
/>
disabled accepts a Date, Date[], a (date: Date) => boolean predicate, OR any of the v9 matcher objects ({ before: Date }, { after: Date }, { from: Date; to: Date }, { dayOfWeek: number[] }). NEVER use the v8 prop name disabledDays.
Pattern 6 : Locale via date-fns adapter
The shadcn wrapper does not expose a locale prop directly ; locale flows through react-day-picker's built-in date-fns adapter via the locale prop, which is passed through ...props :
"use client"
import { nl } from "date-fns/locale"
import { Calendar } from "@/components/ui/calendar"
<Calendar
mode="single"
selected={date}
locale={nl}
/>
For non-Gregorian calendars (Persian, Hijri), import from the dedicated entry points (react-day-picker/persian, react-day-picker/hijri) ; do NOT pass them through locale. See methods.md for the full dateLib adapter signature.
Pattern 7 : Modifiers + modifiersClassNames (semantic day styling)
const holidays = [new Date(2026, 11, 25), new Date(2026, 11, 26)]
const booked = [new Date(2026, 5, 10), new Date(2026, 5, 11)]
<Calendar
mode="single"
selected={date}
modifiers={{ holiday: holidays, booked }}
modifiersClassNames={{
holiday: "bg-red-100 text-red-900",
booked: "line-through opacity-50",
}}
/>
ALWAYS prefer modifiers + modifiersClassNames for semantic-day styling. NEVER patch individual day cells by hand : the next shadcn add calendar --overwrite will erase the patch.
v8 -> v9 migration callout
If the project's components/ui/calendar.tsx was generated before mid-2025 it predates react-day-picker v9 and will silently break after a pnpm update react-day-picker. The fastest recovery is :
pnpm dlx shadcn@latest add calendar --overwrite
This re-pulls the v9-compatible source from the registry. The v8 -> v9 prop renames you will see in old code :
| v8 prop / name | v9 replacement |
|---|---|
fromMonth / fromYear |
startMonth |
toMonth / toYear |
endMonth |
fromDate / toDate |
hidden with { before } / { after } matcher |
selectedDays |
selected |
disabledDays |
disabled |
Caption (component) |
MonthCaption |
Row (component) |
Week |
HeadRow (component) |
Weekdays |
IconLeft + IconRight |
single Chevron with orientation |
day_selected (className key) |
selected |
day_disabled (className key) |
disabled |
cell (className key) |
day |
day (className key) |
day_button |
Full migration table and recovery playbook : shadcn-errors-react-day-picker-v9.
Reference Links
- methods.md : Full prop signatures per mode, DateRange type, dateLib adapter, modifiers + classNames keys
- examples.md : Single / multiple / range Calendar, DatePicker recipe with PopoverButton trigger, Dutch locale via
date-fns/locale/nl, disabled-dates with date-fns predicates - anti-patterns.md : Five Calendar-level anti-patterns and their recoveries
Companion Skills
- shadcn-syntax-popover-tooltip-hovercard : DatePicker recipe relies on Popover + PopoverTrigger + PopoverContent ; consult for
align,side, controlled open state, and Portal stacking concerns. - shadcn-syntax-button : DatePicker trigger is a
<Button variant="outline">rendered viaPopoverTrigger asChild; consult forasChildSlot rules and the variant catalogue. - shadcn-errors-react-day-picker-v9 (B13) : Full v8 -> v9 migration playbook : silent breakage signatures, prop rename table,
shadcn add calendar --overwriterecovery, edge cases in classNames mapping.
Sources
Verified 2026-05-19 against :
- https://ui.shadcn.com/docs/components/radix/calendar
- https://ui.shadcn.com/docs/components/radix/date-picker
apps/v4/registry/new-york-v4/ui/calendar.tsx(shadcn-ui/ui, default branch)apps/v4/registry/new-york-v4/examples/calendar-demo.tsxapps/v4/registry/new-york-v4/examples/date-picker-demo.tsxapps/v4/registry/new-york-v4/examples/date-picker-with-range.tsxpackages/react-day-picker/src/types/shared.ts(gpbl/react-day-picker, default branch) :Mode,DateRange,Matchertypespackages/react-day-picker/src/types/selection.ts:SelectedValue<T>,SelectHandler<T>- https://daypicker.dev/upgrading-v8-to-v10 : v8 -> v9 breaking change list
- shadcn-ui/ui issue #4366 (react-day-picker 9.0.0 release screws up Calendar component, 97 reactions)