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 ( ) }
`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
1. ALWAYS pass `mode` explicitly. NEVER rely on a "default" mode ; v9 has no default and TypeScript will narrow `selected` to `undefined` silently if `mode` is omitted.
2. 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.
3. 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 a `Date` setter for `mode="range"`.
4. ALWAYS treat the DatePicker as a recipe, NOT a component. NEVER write `import { DatePicker } from "@/components/ui/date-picker"` ; there is no such export. Compose `Popover` + `Button` (`asChild` on `PopoverTrigger`) + `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 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 ; 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)
```tsx
"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}
onSelect={setDate}
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}
onSelect={setRange}
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} onSelect={setDate} />
</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}
onSelect={setDate}
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}
onSelect={setDate}
disabled={(d) => isBefore(d, startOfToday())}
/>
// Disable weekends
<Calendar
mode="single"
selected={date}
onSelect={setDate}
disabled={isWeekend}
/>
// Disable a fixed array
<Calendar
mode="single"
selected={date}
onSelect={setDate}
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}
onSelect={setDate}
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}
onSelect={setDate}
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)