Skip to content

Instantly share code, notes, and snippets.

@nhridoy
Last active May 31, 2025 03:21
Show Gist options
  • Save nhridoy/5375503f1a72b2eb05f28fb39dc74325 to your computer and use it in GitHub Desktop.
Save nhridoy/5375503f1a72b2eb05f28fb39dc74325 to your computer and use it in GitHub Desktop.
Shadcn Calendar Component with year and month selector
"use client";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { differenceInCalendarDays, format } 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;
};
type NavView = "days" | "years" | "months";
/**
* 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 = 24,
numberOfMonths,
...props
}: CalendarProps) {
const [navView, setNavView] = React.useState<NavView>("days");
const [selectedYear, setSelectedYear] = React.useState<number>(() => {
// Handle different mode types safely
if (props.mode === "single") {
return (
(props.selected as Date)?.getFullYear() ?? new Date().getFullYear()
);
} else if (props.mode === "range" || props.mode === "multiple") {
const selectedDates = props.selected as Date[] | undefined;
return selectedDates?.[0]?.getFullYear() ?? new Date().getFullYear();
}
return new Date().getFullYear();
});
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 }) => (
<Nav
className={className}
displayYears={displayYears}
navView={navView}
setDisplayYears={setDisplayYears}
startMonth={startMonth}
endMonth={endMonth}
onPrevClick={onPrevClick}
onNextClick={onNextClick}
setSelectedYear={setSelectedYear}
/>
),
CaptionLabel: (props) => (
<CaptionLabel
showYearSwitcher={showYearSwitcher}
navView={navView}
setNavView={setNavView}
displayYears={displayYears}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
{...props}
/>
),
MonthGrid: ({ className, children, ...props }) => (
<MonthGrid
className={className}
displayYears={displayYears}
startMonth={startMonth}
endMonth={endMonth}
navView={navView}
setNavView={setNavView}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
{...props}
>
{children}
</MonthGrid>
),
}}
numberOfMonths={columnsDisplayed}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
function Nav({
className,
navView,
startMonth,
endMonth,
displayYears,
setDisplayYears,
setSelectedYear,
onPrevClick,
onNextClick,
}: {
className?: string;
navView: NavView;
startMonth?: Date;
endMonth?: Date;
displayYears: { from: number; to: number };
setDisplayYears: React.Dispatch<
React.SetStateAction<{ from: number; to: number }>
>;
setSelectedYear: React.Dispatch<React.SetStateAction<number>>;
onPrevClick?: (date: Date) => void;
onNextClick?: (date: Date) => void;
}) {
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;
} else if (navView === "months") {
setSelectedYear((prev) => prev - 1);
setDisplayYears((prev) => ({
from: prev.from - 1,
to: prev.to - 1,
}));
onPrevClick?.(new Date(displayYears.from - 1, 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;
} else if (navView === "months") {
setSelectedYear((prev) => prev + 1);
setDisplayYears((prev) => ({
from: prev.from + 1,
to: prev.to + 1,
}));
onNextClick?.(new Date(displayYears.from + 1, 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>
);
}
function CaptionLabel({
children,
showYearSwitcher,
navView,
setNavView,
displayYears,
selectedYear,
setSelectedYear,
...props
}: {
showYearSwitcher?: boolean;
navView: NavView;
setNavView: React.Dispatch<React.SetStateAction<NavView>>;
displayYears: { from: number; to: number };
selectedYear: number;
setSelectedYear: React.Dispatch<React.SetStateAction<number>>;
} & React.HTMLAttributes<HTMLSpanElement>) {
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"))}
>
{(() => {
if (navView === "days") {
return children;
} else if (navView === "months") {
return selectedYear;
} else {
return displayYears.from + " - " + displayYears.to;
}
})()}
</Button>
);
}
function MonthGrid({
className,
children,
displayYears,
startMonth,
endMonth,
navView,
setNavView,
selectedYear,
setSelectedYear,
...props
}: {
className?: string;
children: React.ReactNode;
displayYears: { from: number; to: number };
startMonth?: Date;
endMonth?: Date;
navView: NavView;
setNavView: React.Dispatch<React.SetStateAction<NavView>>;
selectedYear: number;
setSelectedYear: React.Dispatch<React.SetStateAction<number>>;
} & React.TableHTMLAttributes<HTMLTableElement>) {
if (navView === "years") {
return (
<YearGrid
displayYears={displayYears}
startMonth={startMonth}
endMonth={endMonth}
setNavView={setNavView}
navView={navView}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
className={className}
{...props}
/>
);
} else if (navView === "months") {
return (
<MonthsGrid
setNavView={setNavView}
navView={navView}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
className={className}
{...props}
/>
);
}
return (
<table className={className} {...props}>
{children}
</table>
);
}
function YearGrid({
className,
displayYears,
startMonth,
endMonth,
setNavView,
navView,
selectedYear,
setSelectedYear,
...props
}: {
className?: string;
displayYears: { from: number; to: number };
startMonth?: Date;
endMonth?: Date;
setNavView: React.Dispatch<React.SetStateAction<NavView>>;
navView: NavView;
selectedYear: number;
setSelectedYear: React.Dispatch<React.SetStateAction<number>>;
} & React.HTMLAttributes<HTMLDivElement>) {
const handleYearClick = React.useCallback(
(year: number) => {
setNavView("months");
setSelectedYear(year);
},
[setNavView, setSelectedYear]
);
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;
const isCurrentYear =
displayYears.from + i === new Date().getFullYear();
const isSelectedYear = displayYears.from + i === selectedYear;
return (
<Button
key={i}
className={cn(
"h-7 w-full text-sm font-normal text-foreground",
isCurrentYear && "bg-accent font-medium text-accent-foreground",
isSelectedYear &&
"bg-primary text-primary-foreground font-medium"
)}
variant="ghost"
onClick={() => handleYearClick(displayYears.from + i)}
disabled={navView === "years" ? isDisabled : undefined}
aria-label={`Select year ${displayYears.from + i}`}
aria-selected={displayYears.from + i === selectedYear}
>
{displayYears.from + i}
</Button>
);
}
)}
</div>
);
}
function MonthsGrid({
className,
children,
navView,
setNavView,
selectedYear,
setSelectedYear,
...props
}: {
className?: string;
navView: NavView;
setNavView: React.Dispatch<React.SetStateAction<NavView>>;
selectedYear: number;
setSelectedYear: React.Dispatch<React.SetStateAction<number>>;
} & React.HTMLAttributes<HTMLDivElement>) {
const { goToMonth, selected } = useDayPicker();
const handleMonthClick = React.useCallback(
(month: number) => {
setNavView("days");
goToMonth(new Date(selectedYear, month));
},
[setNavView, goToMonth, selectedYear]
);
return (
<div className={cn("grid grid-cols-4 gap-y-2", className)} {...props}>
{Array.from({ length: 12 }, (_, i) => {
const isCurrentMonth =
i === new Date().getMonth() &&
selectedYear === new Date().getFullYear();
const isSelectedMonth =
i === (selected as Date | undefined)?.getMonth() &&
selectedYear === (selected as Date | undefined)?.getFullYear();
return (
<Button
key={i}
className={cn(
"h-7 w-full text-sm font-normal text-foreground",
isCurrentMonth && "bg-accent font-medium text-accent-foreground",
isSelectedMonth &&
"bg-primary text-primary-foreground font-medium"
)}
variant="ghost"
onClick={() => handleMonthClick(i)}
// disabled={navView === "years" ? isDisabled : undefined}
aria-label={`Select month ${format(new Date(2023, i, 1), "MMMM")}`}
aria-selected={(selected as Date | undefined)?.getMonth() === i}
>
{format(new Date(2023, i, 1), "MMM")}
</Button>
);
})}
</div>
);
}
export { Calendar };
@aswnss-m
Copy link

There are issues with this gist. Its importing serveral modules that does not exists in react-day-picker eg: LabelNext. Is there any update coming ?

@ggcrego
Copy link

ggcrego commented May 22, 2025

Which version of react-day-picker are you using?

@nhridoy
Copy link
Author

nhridoy commented May 22, 2025

There are issues with this gist. Its importing serveral modules that does not exists in react-day-picker eg: LabelNext. Is there any update coming ?

"react-day-picker": "^9.6.7",

But I guess latest version will work also.
https://daypicker.dev/api/functions/labelNext

@nhridoy
Copy link
Author

nhridoy commented May 22, 2025

Which version of react-day-picker are you using?

"react-day-picker": "^9.6.7",

But I guess latest version will work also.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment