Last active
January 17, 2026 21:16
-
-
Save Mr-Vipi/39a11119b8c37696650c2dd22021309d to your computer and use it in GitHub Desktop.
My custom shadcn calendar to include selection of month and year with react-day-picker-v.9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client" | |
| import * as React from "react" | |
| import { | |
| ChevronDownIcon, | |
| ChevronLeftIcon, | |
| ChevronRightIcon, | |
| } from "lucide-react" | |
| import { | |
| ChevronProps, | |
| DayButtonProps, | |
| DayPicker, | |
| getDefaultClassNames, | |
| RootProps, | |
| WeekNumberProps, | |
| } from "react-day-picker" | |
| import { cn } from "@/lib/utils" | |
| import { Button, buttonVariants } from "@/components/ui/button" | |
| export type CalendarProps = React.ComponentPropsWithRef<typeof DayPicker> & { | |
| buttonVariant?: React.ComponentProps<typeof Button>["variant"] | |
| rootClassName?: string | |
| monthsClassName?: string | |
| monthClassName?: string | |
| navClassName?: string | |
| buttonPreviousClassName?: string | |
| buttonNextClassName?: string | |
| monthCaptionClassName?: string | |
| dropdownsClassName?: string | |
| dropdownRootClassName?: string | |
| dropdownClassName?: string | |
| captionLabelClassName?: string | |
| monthGridClassName?: string | |
| weekdaysClassName?: string | |
| weekdayClassName?: string | |
| weekClassName?: string | |
| weekNumberHeaderClassName?: string | |
| weekNumberClassName?: string | |
| dayClassName?: string | |
| rangeStartClassName?: string | |
| rangeMiddleClassName?: string | |
| rangeEndClassName?: string | |
| todayClassName?: string | |
| outsideClassName?: string | |
| disabledClassName?: string | |
| hiddenClassName?: string | |
| footerClassName?: string | |
| } | |
| function Calendar({ | |
| className, | |
| classNames, | |
| showOutsideDays = true, | |
| captionLayout = "label", | |
| buttonVariant = "ghost", | |
| components, | |
| ...props | |
| }: CalendarProps) { | |
| const defaultClassNames = getDefaultClassNames() | |
| const buttonNavClassName = buttonVariants({ | |
| variant: buttonVariant, | |
| className: | |
| "size-[var(--cell-size)] aria-disabled:opacity-50 p-0 select-none", | |
| }) | |
| return ( | |
| <DayPicker | |
| showOutsideDays={showOutsideDays} | |
| className={cn( | |
| "group/calendar bg-background p-3 [--cell-size:calc(.25rem*8)] [[data-slot=card-content]_&]:bg-transparent [[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} | |
| classNames={{ | |
| root: cn("w-fit", defaultClassNames.root, props.rootClassName), | |
| months: cn( | |
| "relative flex flex-col gap-4 md:flex-row", | |
| defaultClassNames.months, | |
| props.monthsClassName | |
| ), | |
| month: cn( | |
| "flex flex-col w-full gap-4", | |
| defaultClassNames.month, | |
| props.monthClassName | |
| ), | |
| nav: cn( | |
| "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", | |
| defaultClassNames.nav, | |
| props.navClassName | |
| ), | |
| button_previous: cn( | |
| buttonNavClassName, | |
| defaultClassNames.button_previous, | |
| props.buttonPreviousClassName | |
| ), | |
| button_next: cn( | |
| buttonNavClassName, | |
| defaultClassNames.button_next, | |
| props.buttonNextClassName | |
| ), | |
| month_caption: cn( | |
| "flex items-center justify-center h-[var(--cell-size)] w-full px-[var(--cell-size)]", | |
| defaultClassNames.month_caption, | |
| props.monthCaptionClassName | |
| ), | |
| dropdowns: cn( | |
| "w-full flex items-center text-sm font-medium justify-center h-[var(--cell-size)] gap-1.5", | |
| defaultClassNames.dropdowns, | |
| props.dropdownsClassName | |
| ), | |
| dropdown_root: cn( | |
| "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md", | |
| defaultClassNames.dropdown_root, | |
| props.dropdownRootClassName | |
| ), | |
| dropdown: cn( | |
| "absolute bg-popover inset-0 opacity-0", | |
| defaultClassNames.dropdown, | |
| props.dropdownClassName | |
| ), | |
| caption_label: cn( | |
| "select-none font-medium", | |
| captionLayout === "label" | |
| ? "text-sm" | |
| : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5", | |
| defaultClassNames.caption_label, | |
| props.captionLabelClassName | |
| ), | |
| month_grid: cn( | |
| "w-full border-collapse", | |
| defaultClassNames.month_grid, | |
| props.monthGridClassName | |
| ), | |
| weekdays: cn( | |
| "flex", | |
| defaultClassNames.weekdays, | |
| props.weekdaysClassName | |
| ), | |
| weekday: cn( | |
| "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none", | |
| defaultClassNames.weekday, | |
| props.weekdayClassName | |
| ), | |
| week: cn( | |
| "flex w-full mt-2", | |
| defaultClassNames.week, | |
| props.weekClassName | |
| ), | |
| week_number_header: cn( | |
| "select-none w-[var(--cell-size)]", | |
| defaultClassNames.week_number_header, | |
| props.weekNumberHeaderClassName | |
| ), | |
| week_number: cn( | |
| "text-[0.8rem] select-none text-muted-foreground", | |
| defaultClassNames.week_number, | |
| props.weekNumberClassName | |
| ), | |
| day: cn( | |
| "relative h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none", | |
| "w-auto [tr:first-child_&]:w-full", | |
| defaultClassNames.day, | |
| props.dayClassName | |
| ), | |
| range_start: cn( | |
| "rounded-l-md bg-accent", | |
| defaultClassNames.range_start, | |
| props.rangeStartClassName | |
| ), | |
| range_middle: cn( | |
| "rounded-none", | |
| defaultClassNames.range_middle, | |
| props.rangeMiddleClassName | |
| ), | |
| range_end: cn( | |
| "rounded-r-md bg-accent", | |
| defaultClassNames.range_end, | |
| props.rangeEndClassName | |
| ), | |
| today: cn( | |
| "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", | |
| defaultClassNames.today, | |
| props.todayClassName | |
| ), | |
| outside: cn( | |
| "text-muted-foreground aria-selected:text-muted-foreground", | |
| defaultClassNames.outside, | |
| props.outsideClassName | |
| ), | |
| disabled: cn( | |
| "text-muted-foreground opacity-50", | |
| defaultClassNames.disabled, | |
| props.disabledClassName | |
| ), | |
| hidden: cn( | |
| "invisible", | |
| defaultClassNames.hidden, | |
| props.hiddenClassName | |
| ), | |
| footer: cn( | |
| "pt-3 text-sm", | |
| defaultClassNames.footer, | |
| props.footerClassName | |
| ), | |
| ...classNames, | |
| }} | |
| components={{ | |
| Root: CalendarRoot, | |
| Chevron: CalendarChevron, | |
| DayButton: CalendarDayButton, | |
| WeekNumber: CalendarWeekNumber, | |
| ...components, | |
| }} | |
| {...props} | |
| /> | |
| ) | |
| } | |
| function CalendarChevron({ | |
| className, | |
| orientation, | |
| ...props | |
| }: Readonly<ChevronProps>) { | |
| 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} /> | |
| } | |
| function CalendarRoot({ className, rootRef, ...props }: RootProps) { | |
| return ( | |
| <div | |
| data-slot="calendar" | |
| ref={rootRef} | |
| className={cn(className)} | |
| {...props} | |
| /> | |
| ) | |
| } | |
| function CalendarWeekNumber({ children, ...props }: WeekNumberProps) { | |
| return ( | |
| <td {...props}> | |
| <div className="flex size-[var(--cell-size)] items-center justify-center text-center"> | |
| {children} | |
| </div> | |
| </td> | |
| ) | |
| } | |
| function CalendarDayButton({ | |
| className, | |
| day, | |
| modifiers, | |
| ...props | |
| }: DayButtonProps) { | |
| 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()} | |
| 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( | |
| "flex aspect-square size-auto w-full min-w-[var(--cell-size)] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-start=true]:rounded-l-md data-[range-end=true]:bg-primary data-[range-middle=true]:bg-accent data-[range-start=true]:bg-primary data-[selected-single=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:text-accent-foreground data-[range-start=true]:text-primary-foreground data-[selected-single=true]:text-primary-foreground 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/50 dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70", | |
| defaultClassNames.day, | |
| className | |
| )} | |
| {...props} | |
| /> | |
| ) | |
| } | |
| Calendar.displayName = "Calendar" | |
| export { Calendar, CalendarDayButton } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { | |
| addDays, | |
| addMonths, | |
| format, | |
| isBefore, | |
| isEqual, | |
| isWithinInterval, | |
| } from "date-fns"; | |
| import { Calendar as CalendarIcon } from "lucide-react"; | |
| import { | |
| Dispatch, | |
| memo, | |
| SetStateAction, | |
| useCallback, | |
| useEffect, | |
| useMemo, | |
| useState, | |
| } from "react"; | |
| import { DateRange } from "react-day-picker"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { cn } from "@/lib/utils"; | |
| // import { FieldType } from "./booking-form"; | |
| type DateRangePickerProps = Readonly<{ | |
| date?: DateRange; | |
| setDate?: Dispatch<SetStateAction<DateRange | undefined>>; | |
| // field?: FieldType; | |
| focusedField?: string; | |
| setFocusedField?: Dispatch<SetStateAction<string>>; | |
| className?: string; | |
| bookedDates?: DateRange[]; | |
| }>; | |
| const fromMonth = new Date(); | |
| const toMonth = addMonths(new Date(), 6); | |
| const DateRangePicker = ({ | |
| // date, | |
| // setDate, | |
| // field, | |
| className, | |
| bookedDates, | |
| }: DateRangePickerProps) => { | |
| const [isPopoverOpen, setIsPopoverOpen] = useState(false); | |
| const [minimumDays, setMinimumDays] = useState<DateRange>({ | |
| from: undefined, | |
| to: undefined, | |
| }); | |
| const [date, setDate] = useState<DateRange | undefined>({ | |
| from: undefined, | |
| to: undefined, | |
| }); | |
| useEffect(() => { | |
| if (!date) { | |
| return; | |
| } | |
| if (date.from && date.to) { | |
| setIsPopoverOpen(false); | |
| setMinimumDays({ | |
| from: undefined, | |
| to: undefined, | |
| }); | |
| return; | |
| } | |
| if (date.from && !date.to) { | |
| setMinimumDays({ | |
| from: addDays(date.from, 1), | |
| to: addDays(date.from, 1), | |
| }); | |
| } else { | |
| setMinimumDays({ | |
| from: undefined, | |
| to: undefined, | |
| }); | |
| } | |
| }, [date]); | |
| function formatDateRange(from: Date, to: Date | undefined) { | |
| const formattedFrom = format(from, "LLL dd, y"); | |
| return to ? `${formattedFrom} - ${format(to, "LLL dd, y")}` : formattedFrom; | |
| } | |
| const getDateText = useCallback( | |
| (/*field: FieldType | undefined,*/ date: DateRange | undefined) => { | |
| // if (field?.value) { | |
| // const { from, to } = field.value; | |
| // return from ? formatDateRange(from, to) : "Departure / Return"; | |
| // } | |
| if (date) { | |
| const { from, to } = date; | |
| return from ? formatDateRange(from, to) : "Departure / Return"; | |
| } | |
| return "Departure / Return"; | |
| }, | |
| [] | |
| ); | |
| const checkIfBookingAvailable = useCallback( | |
| ({ from, to }: DateRange): boolean => { | |
| let isAvailable = true; | |
| if (bookedDates) { | |
| bookedDates.forEach((bookedDate) => { | |
| if ( | |
| !( | |
| bookedDate.from && | |
| bookedDate.to && | |
| from && | |
| to && | |
| isWithinInterval(bookedDate.from, { | |
| start: from, | |
| end: to, | |
| }) && | |
| isWithinInterval(bookedDate.to, { start: from, end: to }) | |
| ) | |
| ) { | |
| return; | |
| } | |
| isAvailable = false; | |
| }); | |
| } | |
| return isAvailable; | |
| }, | |
| [bookedDates] | |
| ); | |
| const updateDate = useCallback( | |
| (day: Date): void => { | |
| if (setDate) { | |
| setDate((prev) => { | |
| if (prev?.from && isEqual(prev.from, day)) { | |
| return { from: undefined, to: undefined }; | |
| } | |
| if (prev?.to) { | |
| return { from: day, to: undefined }; | |
| } else if (prev?.from && isBefore(prev?.from, day)) { | |
| return checkIfBookingAvailable({ from: prev?.from, to: day }) | |
| ? { from: prev?.from, to: day } | |
| : { from: day, to: undefined }; | |
| } else { | |
| return { from: day, to: undefined }; | |
| } | |
| }); | |
| } | |
| // if (!field) { | |
| // return; | |
| // } | |
| // const updateMinimumDaysAndResetToDate = () => { | |
| // setMinimumDays({ from: addDays(day, 1), to: addDays(day, 1) }); | |
| // field.onChange({ from: day, to: undefined }); | |
| // }; | |
| // if (field.value.from && isEqual(field.value.from, day)) { | |
| // field.onChange({ from: undefined, to: undefined }); | |
| // setMinimumDays({ | |
| // from: undefined, | |
| // to: undefined, | |
| // }); | |
| // return; | |
| // } | |
| // if (field.value.to) { | |
| // updateMinimumDaysAndResetToDate(); | |
| // } else if (field.value.from && isBefore(field.value.from, day)) { | |
| // if (checkIfBookingAvailable({ from: field.value.from, to: day })) { | |
| // setIsPopoverOpen(false); | |
| // field.onChange({ from: field.value.from, to: day }); | |
| // setMinimumDays({ from: undefined, to: undefined }); | |
| // } else { | |
| // updateMinimumDaysAndResetToDate(); | |
| // } | |
| // } else { | |
| // updateMinimumDaysAndResetToDate(); | |
| // } | |
| }, | |
| [setDate, /*field,*/ checkIfBookingAvailable] | |
| ); | |
| const handleOpenChange = useCallback( | |
| (value: boolean) => { | |
| setIsPopoverOpen(value); | |
| }, | |
| [setIsPopoverOpen] | |
| ); | |
| const disabled = useMemo( | |
| () => [ | |
| { | |
| before: new Date(), | |
| }, | |
| minimumDays, | |
| ...(bookedDates ?? []), | |
| ], | |
| [minimumDays, bookedDates] | |
| ); | |
| return ( | |
| <Popover open={isPopoverOpen} onOpenChange={handleOpenChange}> | |
| <PopoverTrigger asChild> | |
| <Button | |
| variant="outline" | |
| className={cn( | |
| "w-full pl-3 text-left", | |
| date?.from /*|| field?.value.from*/ ? "" : "text-muted-foreground", | |
| className | |
| )} | |
| > | |
| <span className="pt-1">{getDateText(/*field,*/ date)}</span> | |
| <CalendarIcon className="ml-auto size-4 opacity-50" /> | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0" align="center"> | |
| <Calendar | |
| autoFocus | |
| mode="range" | |
| defaultMonth={date?.from /*|| field?.value?.from*/} | |
| selected={ | |
| date /*|| { | |
| from: field?.value?.from, | |
| to: field?.value?.to, | |
| }*/ | |
| } | |
| numberOfMonths={2} | |
| showOutsideDays={false} | |
| startMonth={fromMonth} | |
| endMonth={toMonth} | |
| onSelect={(_date, day) => updateDate(day)} | |
| disabled={disabled} | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| ); | |
| }; | |
| const areEqual = ( | |
| prevProps: DateRangePickerProps, | |
| nextProps: DateRangePickerProps | |
| ) => { | |
| return ( | |
| prevProps?.date === nextProps.date && | |
| // prevProps?.field === nextProps.field && | |
| prevProps?.focusedField === nextProps.focusedField && | |
| prevProps?.bookedDates === nextProps.bookedDates && | |
| prevProps?.className === nextProps.className | |
| ); | |
| }; | |
| export default memo(DateRangePicker, areEqual); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "use client"; | |
| import { format } from "date-fns"; | |
| import * as React from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { cn } from "@/lib/utils"; | |
| import { CalendarIcon } from "lucide-react"; | |
| // Interval for the date picker | |
| // const startDate = subYears(new Date(), 75); | |
| // const endDate = subYears(new Date(), 23); | |
| export default function DatePicker() { | |
| const [date, setDate] = React.useState<Date>(); | |
| return ( | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <Button | |
| variant="outline" | |
| className={cn( | |
| "w-full pl-3 text-left", | |
| date ? "md:text-sm" : "text-muted-foreground" | |
| )} | |
| > | |
| {date ? format(date, "PPP") : <span>Pick a date</span>} | |
| <CalendarIcon className="ml-auto size-4 opacity-50" /> | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0" align="center"> | |
| <Calendar | |
| mode="single" | |
| selected={date} | |
| onSelect={setDate} | |
| autoFocus | |
| defaultMonth={date} | |
| showOutsideDays={false} | |
| captionLayout="dropdown" | |
| // hideNavigation | |
| // selectTriggerClassName="transition-colors duration-200 ease-in-out hover:border-primary focus:border-primary focus:shadow-around-primary focus:ring-0 focus:ring-offset-0" | |
| // startMonth={startDate} | |
| // endMonth={endDate} | |
| // disabled={[ | |
| // { before: startDate }, | |
| // { after: endDate }, | |
| // ]} | |
| // footer={DatePickerFooter} | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment