This custom shadcn component aims to provide a more advanced alternative to the default date picker component. It is built on top of the react-day-picker
library, which provides a wide range of customization options.
Furthermore, it allows usage with react-day-picker
version 9, which is not compatible with the current shadcn date picker component.
In the demo above, notice that you can click on the moth label at the top to change the view to years.
Install react-day-picker
Install the package by running the following command in your terminal:
pnpm add react-day-picker@9.4.3
Note
It is important to have at least the version 9 of the react-day-picker package installed.For more information on how to install the package, check the official documentation for getting started here.
Update the Calendar component
Update Required
In case you have already used the calendar or date picker component from shadcn, you will need to update it with the following content. We are usingreact-day-picker
version 9, while shadcn is still on v8, which are not compatible with each other.If you are starting from scratch, follow the instructions over here.
Once you have a working project with shadcn, create acomponents/ui/calendar.tsx
file and paste the following code:components/ui/calendar.tsx
"use client" import { Button, buttonVariants } from "@/components/ui/button" import { cn } from "@/lib/utils" import { differenceInCalendarDays } from "date-fns" import { ChevronLeft, ChevronRight } from "lucide-react" import * as React from "react" import { DayPicker, labelNext, labelPrevious, useDayPicker, type DayPickerProps, } from "react-day-picker" export type CalendarProps = DayPickerProps & { /** * In the year view, the number of years to display at once. * @default 12 */ yearRange?: number /** * Wether to show the year switcher in the caption. * @default true */ showYearSwitcher?: boolean monthsClassName?: string monthCaptionClassName?: string weekdaysClassName?: string weekdayClassName?: string monthClassName?: string captionClassName?: string captionLabelClassName?: string buttonNextClassName?: string buttonPreviousClassName?: string navClassName?: string monthGridClassName?: string weekClassName?: string dayClassName?: string dayButtonClassName?: string rangeStartClassName?: string rangeEndClassName?: string selectedClassName?: string todayClassName?: string outsideClassName?: string disabledClassName?: string rangeMiddleClassName?: string hiddenClassName?: string } /** * A custom calendar component built on top of react-day-picker. * @param props The props for the calendar. * @default yearRange 12 * @returns */ function Calendar({ className, showOutsideDays = true, showYearSwitcher = true, yearRange = 12, numberOfMonths, ...props }: CalendarProps) { const [navView, setNavView] = React.useState<"days" | "years">("days") const [displayYears, setDisplayYears] = React.useState<{ from: number to: number }>( React.useMemo(() => { const currentYear = new Date().getFullYear() return { from: currentYear - Math.floor(yearRange / 2 - 1), to: currentYear + Math.ceil(yearRange / 2), } }, [yearRange]) ) const { onNextClick, onPrevClick, startMonth, endMonth } = props const columnsDisplayed = navView === "years" ? 1 : numberOfMonths const _monthsClassName = cn("relative flex", props.monthsClassName) const _monthCaptionClassName = cn( "relative mx-10 flex h-7 items-center justify-center", props.monthCaptionClassName ) const _weekdaysClassName = cn("flex flex-row", props.weekdaysClassName) const _weekdayClassName = cn( "w-8 text-sm font-normal text-muted-foreground", props.weekdayClassName ) const _monthClassName = cn("w-full", props.monthClassName) const _captionClassName = cn( "relative flex items-center justify-center pt-1", props.captionClassName ) const _captionLabelClassName = cn( "truncate text-sm font-medium", props.captionLabelClassName ) const buttonNavClassName = buttonVariants({ variant: "outline", className: "absolute h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", }) const _buttonNextClassName = cn( buttonNavClassName, "right-0", props.buttonNextClassName ) const _buttonPreviousClassName = cn( buttonNavClassName, "left-0", props.buttonPreviousClassName ) const _navClassName = cn("flex items-start", props.navClassName) const _monthGridClassName = cn("mx-auto mt-4", props.monthGridClassName) const _weekClassName = cn("mt-2 flex w-max items-start", props.weekClassName) const _dayClassName = cn( "flex size-8 flex-1 items-center justify-center p-0 text-sm", props.dayClassName ) const _dayButtonClassName = cn( buttonVariants({ variant: "ghost" }), "size-8 rounded-md p-0 font-normal transition-none aria-selected:opacity-100", props.dayButtonClassName ) const buttonRangeClassName = "bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground" const _rangeStartClassName = cn( buttonRangeClassName, "day-range-start rounded-s-md", props.rangeStartClassName ) const _rangeEndClassName = cn( buttonRangeClassName, "day-range-end rounded-e-md", props.rangeEndClassName ) const _rangeMiddleClassName = cn( "bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground", props.rangeMiddleClassName ) const _selectedClassName = cn( "[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", props.selectedClassName ) const _todayClassName = cn( "[&>button]:bg-accent [&>button]:text-accent-foreground", props.todayClassName ) const _outsideClassName = cn( "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", props.outsideClassName ) const _disabledClassName = cn( "text-muted-foreground opacity-50", props.disabledClassName ) const _hiddenClassName = cn("invisible flex-1", props.hiddenClassName) return ( <DayPicker showOutsideDays={showOutsideDays} className={cn("p-3", className)} style={{ width: 248.8 * (columnsDisplayed ?? 1) + "px", }} classNames={{ months: _monthsClassName, month_caption: _monthCaptionClassName, weekdays: _weekdaysClassName, weekday: _weekdayClassName, month: _monthClassName, caption: _captionClassName, caption_label: _captionLabelClassName, button_next: _buttonNextClassName, button_previous: _buttonPreviousClassName, nav: _navClassName, month_grid: _monthGridClassName, week: _weekClassName, day: _dayClassName, day_button: _dayButtonClassName, range_start: _rangeStartClassName, range_middle: _rangeMiddleClassName, range_end: _rangeEndClassName, selected: _selectedClassName, today: _todayClassName, outside: _outsideClassName, disabled: _disabledClassName, hidden: _hiddenClassName, }} components={{ Chevron: ({ orientation }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight return <Icon className="h-4 w-4" /> }, Nav: ({ className }) => { const { nextMonth, previousMonth, goToMonth } = useDayPicker() const isPreviousDisabled = (() => { if (navView === "years") { return ( (startMonth && differenceInCalendarDays( new Date(displayYears.from - 1, 0, 1), startMonth ) < 0) || (endMonth && differenceInCalendarDays( new Date(displayYears.from - 1, 0, 1), endMonth ) > 0) ) } return !previousMonth })() const isNextDisabled = (() => { if (navView === "years") { return ( (startMonth && differenceInCalendarDays( new Date(displayYears.to + 1, 0, 1), startMonth ) < 0) || (endMonth && differenceInCalendarDays( new Date(displayYears.to + 1, 0, 1), endMonth ) > 0) ) } return !nextMonth })() const handlePreviousClick = React.useCallback(() => { if (!previousMonth) return if (navView === "years") { setDisplayYears((prev) => ({ from: prev.from - (prev.to - prev.from + 1), to: prev.to - (prev.to - prev.from + 1), })) onPrevClick?.( new Date( displayYears.from - (displayYears.to - displayYears.from), 0, 1 ) ) return } goToMonth(previousMonth) onPrevClick?.(previousMonth) }, [previousMonth, goToMonth]) const handleNextClick = React.useCallback(() => { if (!nextMonth) return if (navView === "years") { setDisplayYears((prev) => ({ from: prev.from + (prev.to - prev.from + 1), to: prev.to + (prev.to - prev.from + 1), })) onNextClick?.( new Date( displayYears.from + (displayYears.to - displayYears.from), 0, 1 ) ) return } goToMonth(nextMonth) onNextClick?.(nextMonth) }, [goToMonth, nextMonth]) return ( <nav className={cn("flex items-center", className)}> <Button variant="outline" className="absolute left-0 h-7 w-7 bg-transparent p-0 opacity-80 hover:opacity-100" type="button" tabIndex={isPreviousDisabled ? undefined : -1} disabled={isPreviousDisabled} aria-label={ navView === "years" ? `Go to the previous ${ displayYears.to - displayYears.from + 1 } years` : labelPrevious(previousMonth) } onClick={handlePreviousClick} > <ChevronLeft className="h-4 w-4" /> </Button> <Button variant="outline" className="absolute right-0 h-7 w-7 bg-transparent p-0 opacity-80 hover:opacity-100" type="button" tabIndex={isNextDisabled ? undefined : -1} disabled={isNextDisabled} aria-label={ navView === "years" ? `Go to the next ${ displayYears.to - displayYears.from + 1 } years` : labelNext(nextMonth) } onClick={handleNextClick} > <ChevronRight className="h-4 w-4" /> </Button> </nav> ) }, CaptionLabel: ({ children, ...props }) => { if (!showYearSwitcher) return <span {...props}>{children}</span> return ( <Button className="h-7 w-full truncate text-sm font-medium" variant="ghost" size="sm" onClick={() => setNavView((prev) => (prev === "days" ? "years" : "days")) } > {navView === "days" ? children : displayYears.from + " - " + displayYears.to} </Button> ) }, MonthGrid: ({ className, children, ...props }) => { const { goToMonth, selected } = useDayPicker() if (navView === "years") { return ( <div className={cn("grid grid-cols-4 gap-y-2", className)} {...props} > {Array.from( { length: displayYears.to - displayYears.from + 1 }, (_, i) => { const isBefore = differenceInCalendarDays( new Date(displayYears.from + i, 11, 31), startMonth! ) < 0 const isAfter = differenceInCalendarDays( new Date(displayYears.from + i, 0, 0), endMonth! ) > 0 const isDisabled = isBefore || isAfter return ( <Button key={i} className={cn( "h-7 w-full text-sm font-normal text-foreground", displayYears.from + i === new Date().getFullYear() && "bg-accent font-medium text-accent-foreground" )} variant="ghost" onClick={() => { setNavView("days") goToMonth( new Date( displayYears.from + i, (selected as Date | undefined)?.getMonth() ?? 0 ) ) }} disabled={navView === "years" ? isDisabled : undefined} > {displayYears.from + i} </Button> ) } )} </div> ) } return ( <table className={className} {...props}> {children} </table> ) }, }} numberOfMonths={columnsDisplayed} {...props} /> ) } Calendar.displayName = "Calendar" export { Calendar }
Date Picker Component
Now create a new date-picker file and paste the following code, depending on your use case.Basic Date Picker
This is the basic date picker component that allows users to select a single date. The main advantage of this component over the shadcn default date picker is the ability navigate through years instead of only one month at a time. This is especially useful when selecting a date of birth.
Date Range Picker
This is the date range picker component that allows users to select a range of dates. It also allows users to navigate through years. This is especially useful when selecting a very long range of dates, such as a holiday period.
Done!
Buy me a coffeeIf you find any bugs or want to suggest improvements, feel free do so. This component is free to use, but if you find it useful, feel free to star the repository on GitHub.
If you want to be extra cool, you can also buy me a coffee and I will be forever grateful.