Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/calendar.jsonInstalls to @/components/ui/calendar.
Manual installation
Install the dependencies:
pnpm add lucide-react@^1.48.0 react-day-picker@^10.0.1Add the components it builds on: button.
Copy the source into your project:
'use client'
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'
import { type DayButton, DayPicker, getDefaultClassNames, type Locale, useDayPicker } from 'react-day-picker'
import { Button, buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
// Weeks start on Monday by default, as in the Nordic countries; pass weekStartsOn to override
weekStartsOn = 1,
locale,
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
weekStartsOn={weekStartsOn}
className={cn(
'group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(9)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString(locale?.code, { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative rounded-(--cell-radius) has-focus:ring-3 has-focus:ring-ring',
defaultClassNames.dropdown_root,
),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
day: cn(
'group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)'
: '[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)',
defaultClassNames.day,
),
range_start: cn(
'relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted',
defaultClassNames.range_start,
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn(
'relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted',
defaultClassNames.range_end,
),
today: cn(
'rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return <ChevronLeftIcon className={cn('size-4', className)} {...props} />
}
if (orientation === 'right') {
return <ChevronRightIcon className={cn('size-4', className)} {...props} />
}
return <ChevronDownIcon className={cn('size-4', className)} {...props} />
},
DayButton: CalendarLocalizedDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
/** The day button `Calendar` hands to DayPicker: a `CalendarDayButton` in the locale the calendar was given. */
function CalendarLocalizedDayButton(props: Readonly<React.ComponentProps<typeof DayButton>>) {
const { dayPickerProps } = useDayPicker()
return <CalendarDayButton locale={dayPickerProps.locale} {...props} />
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }A flexible and accessible calendar component built on top of react-day-picker v10. Supports single date selection, date ranges, multiple dates, and various display modes with full keyboard navigation and screen reader support.
Features
- Single date, date range, and multiple date selection modes
- Dropdown navigation for months and years
- Caption layouts:
label,dropdown,dropdown-months,dropdown-years - Week numbers display
- Weeks start on Monday by default (
weekStartsOn={1}); passweekStartsOn={0}for Sunday - Disabled dates and date ranges
- Multiple month display
- Accessible keyboard navigation
- Screen reader support
- Custom styling with Tailwind CSS
- Localization via the
localeprop (e.g.svfromreact-day-picker/locale) - Outside days shown by default (
showOutsideDays) - Configurable navigation button variant (
buttonVariant)
Usage
import { Calendar } from '@/components/ui/calendar'
import type { DateRange } from 'react-day-picker'
// Basic usage
export function MyComponent() {
const [selected, setSelected] = React.useState<Date>()
return (
<Calendar
mode="single"
selected={selected}
onSelect={setSelected}
/>
)
}
// Date range selection
export function DateRangeComponent() {
const [selected, setSelected] = React.useState<DateRange>()
return (
<Calendar
mode="range"
selected={selected}
onSelect={setSelected}
/>
)
}
// Multiple date selection
export function MultipleDatesComponent() {
const [selected, setSelected] = React.useState<Date[]>([])
return (
<Calendar
mode="multiple"
selected={selected}
onSelect={setSelected}
/>
)
}Examples
Default
A single date, opened on a fixed month with defaultMonth. Weeks start on Monday.
With Dropdown Navigation
Month and year dropdowns (captionLayout="dropdown") make dates far back, like a date of birth, quick to reach.
startMonth and endMonth limit the years in the dropdown.
Date Range Selection
mode="range" inside a card, for pausing home help while the resident is away. The footer summarises the selection.
Multiple Date Selection
mode="multiple" for picking individual days, with weekends disabled.
With Preselected Date
With required, a date is always selected and onSelect always receives a Date.
With Disabled Dates
A booking card where past days, weekends and fully booked days are disabled by passing matchers and dates to
disabled, next to a list of time slots.
Multiple Months Display
Two months side by side (numberOfMonths={2}) make ranges that cross a month boundary easier to select.
With Week Numbers
showWeekNumber adds ISO week numbers, which are widely used in the Nordic countries. Custom modifiers and
modifiersClassNames mark waste collection days, with a legend that explains the marker.
Custom Week Start
Weeks start on Monday by default. Pass weekStartsOn to change it, e.g. weekStartsOn={0} for Sunday.
With Min/Max Dates
Calendar with restricted date selection and navigation range (disabled={{ before, after }} together with
startMonth / endMonth).
Caption Layouts
Month-only and year-only dropdowns.
With Locale
Swedish and Finnish month and weekday names via locale={sv} and locale={fi}.
Outline Navigation
Navigation buttons rendered with buttonVariant="outline".
Fixed Weeks
Always render six weeks per month, so the height doesn’t change when navigating.
Props
Calendar
mode- Selection mode ('single' | 'multiple' | 'range')selected- Currently selected date(s)onSelect- Callback when selection changescaptionLayout- Layout of the month/year caption ('label' | 'dropdown' | 'dropdown-months' | 'dropdown-years'), defaults to 'label'showOutsideDays- Whether to show days from adjacent months (defaults totrue)numberOfMonths- Number of months to display simultaneouslyweekStartsOn- First day of the week (0=Sunday, 1=Monday, etc.); defaults to1(Monday)showWeekNumber- Whether to display week numbersfixedWeeks- Always display six weeksdisabled- Dates or matchers to disable (e.g.{ before: date },{ after: date },{ dayOfWeek: [0, 6] })startMonth/endMonth- Navigation boundaries (also limit the dropdown years)locale- A date-fns / react-day-picker localetimeZone- IANA time zone for calendar mathclassName- Additional CSS classesbuttonVariant-Buttonvariant for the navigation buttons ('default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link'), defaults to 'ghost'
All other react-day-picker DayPicker props are supported. CalendarDayButton is also exported for custom
components={{ DayButton }} overrides.
Date Selection Types
Single Mode
const [selected, setSelected] = React.useState<Date>()
<Calendar mode="single" selected={selected} onSelect={setSelected} />Range Mode
const [selected, setSelected] = React.useState<DateRange>()
<Calendar mode="range" selected={selected} onSelect={setSelected} />Multiple Mode
const [selected, setSelected] = React.useState<Date[]>([])
<Calendar mode="multiple" selected={selected} onSelect={setSelected} />Accessibility
The Calendar component follows WAI-ARIA guidelines:
- Uses proper ARIA roles (
grid,gridcell,button) - Supports comprehensive keyboard navigation:
Arrow keys: Navigate between datesSpace/Enter: Select datePage Up/Down: Navigate monthsShift + Page Up/Down: Navigate yearsHome/End: Navigate to week start/endCtrl + Home: Navigate to today
- Announces date changes to screen readers
- Maintains focus management during navigation
- Provides clear visual feedback for all states
- Supports high contrast mode
Best Practices
-
Date Selection:
- Provide clear visual feedback for selected dates
- Use appropriate selection mode for the use case
- Consider date range limitations when needed
-
Navigation:
- Use dropdown navigation for large date ranges
- Provide year boundaries to prevent excessive scrolling
- Consider starting week day based on user locale
-
Accessibility:
- Ensure sufficient color contrast for all states
- Provide keyboard navigation instructions
- Test with screen readers
- Maintain logical tab order
-
User Experience:
- Show multiple months for range selection
- Disable irrelevant dates clearly
- Provide feedback for selected dates
- Consider mobile touch targets
-
Localization:
- Configure week start day based on locale
- Use appropriate date formatting
- Support right-to-left languages
- Provide translated month/day names
Related Components
- Datetime Picker - For date and time selection
- Time Picker - For time selection
- Popover - For calendar dropdown implementations