diff --git a/.changeset/visible-months-range-panels.md b/.changeset/visible-months-range-panels.md
new file mode 100644
index 0000000000..3e0f76550c
--- /dev/null
+++ b/.changeset/visible-months-range-panels.md
@@ -0,0 +1,5 @@
+---
+"react-day-picker": minor
+---
+
+feat: add visible months navigation for independent range panels.
diff --git a/packages/react-day-picker/src/DayPicker.test.tsx b/packages/react-day-picker/src/DayPicker.test.tsx
index bc0359837b..069896daca 100644
--- a/packages/react-day-picker/src/DayPicker.test.tsx
+++ b/packages/react-day-picker/src/DayPicker.test.tsx
@@ -658,6 +658,160 @@ describe("when using reversed dropdowns with numberOfMonths > 1 (issue #2741)",
});
});
+describe("when using visibleMonths in range mode", () => {
+ const january2024 = new Date(2024, 0, 1);
+ const december2025 = new Date(2025, 11, 1);
+
+ test("renders the visible months", () => {
+ render(
+ ,
+ );
+
+ expect(grid("January 2024")).toBeInTheDocument();
+ expect(grid("December 2025")).toBeInTheDocument();
+ });
+
+ test("changes only the selected panel from its month dropdown", async () => {
+ const handleVisibleMonthsChange = jest.fn();
+
+ function TestDayPicker() {
+ const [visibleMonths, setVisibleMonths] = React.useState([
+ january2024,
+ december2025,
+ ]);
+
+ return (
+ {
+ handleVisibleMonthsChange(months, context);
+ setVisibleMonths(months);
+ }}
+ visibleMonths={visibleMonths}
+ />
+ );
+ }
+
+ render();
+
+ const secondMonthDropdown = screen.getAllByRole("combobox", {
+ name: labelMonthDropdown(),
+ })[1];
+
+ await user.selectOptions(secondMonthDropdown, "5");
+
+ const grids = screen.getAllByRole("grid");
+ expect(grids[0]).toHaveAccessibleName("January 2024");
+ expect(grids[1]).toHaveAccessibleName("June 2025");
+ expect(handleVisibleMonthsChange).toHaveBeenCalledWith(
+ [january2024, new Date(2025, 5, 1)],
+ {
+ changedIndex: 1,
+ month: new Date(2025, 5, 1),
+ source: "dropdown",
+ },
+ );
+ });
+
+ test("updates only the first visible month when clicking previous", async () => {
+ render(
+ ,
+ );
+
+ await user.click(previousButton());
+
+ const grids = screen.getAllByRole("grid");
+ expect(grids[0]).toHaveAccessibleName("December 2023");
+ expect(grids[1]).toHaveAccessibleName("December 2025");
+ });
+
+ test("updates only the last visible month when clicking next", async () => {
+ render(
+ ,
+ );
+
+ await user.click(nextButton());
+
+ const grids = screen.getAllByRole("grid");
+ expect(grids[0]).toHaveAccessibleName("January 2024");
+ expect(grids[1]).toHaveAccessibleName("January 2026");
+ });
+
+ test("allows dropdown navigation to make visible months non-chronological", async () => {
+ const handleVisibleMonthsChange = jest.fn();
+ const handleSelect = jest.fn();
+
+ render(
+ ,
+ );
+
+ const firstMonthDropdown = screen.getAllByRole("combobox", {
+ name: labelMonthDropdown(),
+ })[0];
+
+ await user.selectOptions(firstMonthDropdown, "2");
+
+ const grids = screen.getAllByRole("grid");
+ expect(grids[0]).toHaveAccessibleName("March 2024");
+ expect(grids[1]).toHaveAccessibleName("February 2024");
+ expect(dateButton(new Date(2024, 2, 15))).toBeInTheDocument();
+ expect(dateButton(new Date(2024, 1, 15))).toBeInTheDocument();
+ expect(handleVisibleMonthsChange).toHaveBeenCalledWith(
+ [new Date(2024, 2, 1), new Date(2024, 1, 1)],
+ {
+ changedIndex: 0,
+ month: new Date(2024, 2, 1),
+ source: "dropdown",
+ },
+ );
+ expect(handleSelect).not.toHaveBeenCalled();
+ });
+
+ test("changes the focused panel from keyboard navigation", async () => {
+ const handleVisibleMonthsChange = jest.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(dateButton(new Date(2024, 0, 15)));
+ await user.keyboard("{PageDown}");
+
+ const grids = screen.getAllByRole("grid");
+ expect(grids[0]).toHaveAccessibleName("February 2024");
+ expect(grids[1]).toHaveAccessibleName("December 2025");
+ expect(handleVisibleMonthsChange).toHaveBeenCalledWith(
+ [new Date(2024, 1, 1), december2025],
+ {
+ changedIndex: 0,
+ month: new Date(2024, 1, 1),
+ source: "keyboard",
+ },
+ );
+ });
+});
+
test("should render the custom components", () => {
render(
+ toTimeZone(month, timeZone),
+ );
+ }
+ if (props.defaultVisibleMonths) {
+ props.defaultVisibleMonths = props.defaultVisibleMonths.map((month) =>
+ toTimeZone(month, timeZone),
+ );
+ }
if (props.startMonth) {
props.startMonth = toTimeZone(props.startMonth, timeZone);
}
@@ -161,7 +171,6 @@ export function DayPicker(initialProps: DayPickerProps) {
captionLayout,
mode,
navLayout,
- numberOfMonths = 1,
onDayBlur,
onDayClick,
onDayFocus,
@@ -194,6 +203,8 @@ export function DayPicker(initialProps: DayPickerProps) {
previousMonth,
nextMonth,
goToMonth,
+ goToVisibleMonth,
+ usesVisibleMonths,
} = calendar;
const getModifiers = createGetModifiers(
@@ -242,15 +253,46 @@ export function DayPicker(initialProps: DayPickerProps) {
const handlePreviousClick = useCallback(() => {
if (!previousMonth) return;
+ if (usesVisibleMonths) {
+ const changedMonth = goToVisibleMonth(0, previousMonth, "navigation");
+ if (changedMonth) {
+ onPrevClick?.(changedMonth);
+ }
+ return;
+ }
goToMonth(previousMonth);
onPrevClick?.(previousMonth);
- }, [previousMonth, goToMonth, onPrevClick]);
+ }, [
+ previousMonth,
+ usesVisibleMonths,
+ goToVisibleMonth,
+ goToMonth,
+ onPrevClick,
+ ]);
const handleNextClick = useCallback(() => {
if (!nextMonth) return;
+ if (usesVisibleMonths) {
+ const changedMonth = goToVisibleMonth(
+ months.length - 1,
+ nextMonth,
+ "navigation",
+ );
+ if (changedMonth) {
+ onNextClick?.(changedMonth);
+ }
+ return;
+ }
goToMonth(nextMonth);
onNextClick?.(nextMonth);
- }, [goToMonth, nextMonth, onNextClick]);
+ }, [
+ goToMonth,
+ goToVisibleMonth,
+ months.length,
+ nextMonth,
+ onNextClick,
+ usesVisibleMonths,
+ ]);
const handleDayClick = useCallback(
(day: CalendarDay, m: Modifiers) => (e: MouseEvent) => {
@@ -326,26 +368,34 @@ export function DayPicker(initialProps: DayPickerProps) {
);
const handleMonthChange = useCallback(
- (date: Date, monthOffset: number) =>
+ (date: Date, monthOffset: number, displayIndex: number) =>
(e: ChangeEvent) => {
const selectedMonth = Number(e.target.value);
const month = dateLib.setMonth(
dateLib.startOfMonth(date),
selectedMonth,
);
+ if (usesVisibleMonths) {
+ goToVisibleMonth(displayIndex, month, "dropdown");
+ return;
+ }
goToMonth(dateLib.addMonths(month, -monthOffset));
},
- [dateLib, goToMonth],
+ [dateLib, goToMonth, goToVisibleMonth, usesVisibleMonths],
);
const handleYearChange = useCallback(
- (date: Date, monthOffset: number) =>
+ (date: Date, monthOffset: number, displayIndex: number) =>
(e: ChangeEvent) => {
const selectedYear = Number(e.target.value);
const month = dateLib.setYear(dateLib.startOfMonth(date), selectedYear);
+ if (usesVisibleMonths) {
+ goToVisibleMonth(displayIndex, month, "dropdown");
+ return;
+ }
goToMonth(dateLib.addMonths(month, -monthOffset));
},
- [dateLib, goToMonth],
+ [dateLib, goToMonth, goToVisibleMonth, usesVisibleMonths],
);
const { className, style } = useMemo(
@@ -491,6 +541,7 @@ export function DayPicker(initialProps: DayPickerProps) {
onChange={handleMonthChange(
calendarMonth.date,
monthOffset,
+ displayIndex,
)}
options={getMonthOptions(
calendarMonth.date,
@@ -519,6 +570,7 @@ export function DayPicker(initialProps: DayPickerProps) {
onChange={handleYearChange(
calendarMonth.date,
monthOffset,
+ displayIndex,
)}
options={getYearOptions(
navStart,
@@ -583,7 +635,7 @@ export function DayPicker(initialProps: DayPickerProps) {
{navLayout === "around" &&
!props.hideNavigation &&
- displayIndex === numberOfMonths - 1 && (
+ displayIndex === months.length - 1 && (
)}
- {displayIndex === numberOfMonths - 1 &&
+ {displayIndex === months.length - 1 &&
navLayout === "after" &&
!props.hideNavigation && (
{
+ const displayedMonths = props.visibleMonths ?? props.defaultVisibleMonths;
const dataAttributes: Record = {
"data-mode": props.mode ?? undefined,
"data-required": "required" in props ? props.required : undefined,
"data-multiple-months":
- (props.numberOfMonths && props.numberOfMonths > 1) || undefined,
+ (displayedMonths
+ ? displayedMonths.length > 1
+ : props.numberOfMonths && props.numberOfMonths > 1) || undefined,
"data-week-numbers": props.showWeekNumber || undefined,
"data-broadcast-calendar": props.broadcastCalendar || undefined,
"data-nav-layout": props.navLayout || undefined,
diff --git a/packages/react-day-picker/src/helpers/getDates.test.ts b/packages/react-day-picker/src/helpers/getDates.test.ts
index 5a15c161e6..9c4f94bfa2 100644
--- a/packages/react-day-picker/src/helpers/getDates.test.ts
+++ b/packages/react-day-picker/src/helpers/getDates.test.ts
@@ -203,6 +203,22 @@ describe("when the first month and the last month are different", () => {
expect(dates[dates.length - 1]).toEqual(new Date(2024, 0, 6));
});
});
+ describe("when the display months are not chronological", () => {
+ const firstMonth = new Date(2026, 0, 1);
+ const lastMonth = new Date(2025, 10, 1);
+
+ test("should return dates between the earliest and latest months", () => {
+ const dates = getDates(
+ [firstMonth, lastMonth],
+ undefined,
+ { fixedWeeks: false },
+ defaultDateLib,
+ );
+
+ expect(dates[0]).toEqual(new Date(2025, 9, 26));
+ expect(dates[dates.length - 1]).toEqual(new Date(2026, 0, 31));
+ });
+ });
describe("when using a max date", () => {
const firstMonth = new Date(2023, 4, 1);
const lastMonth = new Date(2023, 11, 1);
diff --git a/packages/react-day-picker/src/helpers/getDates.ts b/packages/react-day-picker/src/helpers/getDates.ts
index cdb1e91a38..cb4ce43f6c 100644
--- a/packages/react-day-picker/src/helpers/getDates.ts
+++ b/packages/react-day-picker/src/helpers/getDates.ts
@@ -19,8 +19,8 @@ export function getDates(
props: Pick,
dateLib: DateLib,
): Date[] {
- const firstMonth = displayMonths[0];
- const lastMonth = displayMonths[displayMonths.length - 1];
+ const firstMonth = dateLib.min(displayMonths);
+ const lastMonth = dateLib.max(displayMonths);
const { ISOWeek, fixedWeeks, broadcastCalendar } = props ?? {};
const {
diff --git a/packages/react-day-picker/src/types/props.test.tsx b/packages/react-day-picker/src/types/props.test.tsx
index 0133f147d2..7e12c8bc6d 100644
--- a/packages/react-day-picker/src/types/props.test.tsx
+++ b/packages/react-day-picker/src/types/props.test.tsx
@@ -50,6 +50,8 @@ const dateShapedProps: DayPickerProps = {
today: date,
month,
defaultMonth: month,
+ visibleMonths: [month, endMonth],
+ defaultVisibleMonths: [month, endMonth],
startMonth: month,
endMonth,
disabled: dateMatchers,
@@ -69,6 +71,16 @@ const dateShapedProps: DayPickerProps = {
onMonthChange: (changedMonth) => {
void (changedMonth satisfies Date);
},
+ onVisibleMonthsChange: (visibleMonths, context) => {
+ void (visibleMonths satisfies Date[]);
+ void (context.changedIndex satisfies number | undefined);
+ void (context.month satisfies Date | undefined);
+ void (context.source satisfies
+ | "navigation"
+ | "dropdown"
+ | "keyboard"
+ | undefined);
+ },
onNextClick: (nextMonth) => {
void (nextMonth satisfies Date);
},
@@ -179,6 +191,10 @@ const Test = () => {
{/* @ts-expect-error `defaultMonth` must be a Date */}
+ {/* @ts-expect-error `visibleMonths` must contain Date values */}
+
+ {/* @ts-expect-error `defaultVisibleMonths` must contain Date values */}
+
{/* @ts-expect-error `startMonth` must be a Date */}
{/* @ts-expect-error `endMonth` must be a Date */}
diff --git a/packages/react-day-picker/src/types/props.ts b/packages/react-day-picker/src/types/props.ts
index 75e201819a..38f7029022 100644
--- a/packages/react-day-picker/src/types/props.ts
+++ b/packages/react-day-picker/src/types/props.ts
@@ -16,6 +16,7 @@ import type {
MonthChangeEventHandler,
Numerals,
Styles,
+ VisibleMonthsChangeEventHandler,
} from "./shared.js";
/**
@@ -108,6 +109,18 @@ export interface PropsBase {
* @see https://daypicker.dev/docs/navigation
*/
month?: Date;
+ /**
+ * The months displayed in the calendar.
+ *
+ * Use this prop with {@link onVisibleMonthsChange} to control each displayed
+ * month independently.
+ */
+ visibleMonths?: Date[];
+ /**
+ * The initial months to show in the calendar when using independently
+ * controlled visible months.
+ */
+ defaultVisibleMonths?: Date[];
/**
* The number of displayed months.
*
@@ -456,6 +469,11 @@ export interface PropsBase {
*/
onMonthChange?: MonthChangeEventHandler;
+ /**
+ * Event fired when the visible months change.
+ */
+ onVisibleMonthsChange?: VisibleMonthsChangeEventHandler;
+
/**
* Event handler when the next month button is clicked.
*
diff --git a/packages/react-day-picker/src/types/shared.ts b/packages/react-day-picker/src/types/shared.ts
index 0da93d6d8b..c360722922 100644
--- a/packages/react-day-picker/src/types/shared.ts
+++ b/packages/react-day-picker/src/types/shared.ts
@@ -238,6 +238,28 @@ export type DayEventHandler = (
*/
export type MonthChangeEventHandler = (month: Date) => void;
+/**
+ * The source of a visible months change.
+ */
+export type VisibleMonthsChangeSource = "navigation" | "dropdown" | "keyboard";
+
+/**
+ * Additional information about a visible months change.
+ */
+export type VisibleMonthsChangeContext = {
+ changedIndex?: number;
+ month?: Date;
+ source?: VisibleMonthsChangeSource;
+};
+
+/**
+ * The event handler when the visible months are changed in the calendar.
+ */
+export type VisibleMonthsChangeEventHandler = (
+ months: Date[],
+ context: VisibleMonthsChangeContext,
+) => void;
+
/**
* The CSS classnames to use for the {@link UI} elements, the
* {@link SelectionState} and the {@link DayFlag}.
diff --git a/packages/react-day-picker/src/useCalendar.ts b/packages/react-day-picker/src/useCalendar.ts
index 44a02c0f11..02bf5b2b69 100644
--- a/packages/react-day-picker/src/useCalendar.ts
+++ b/packages/react-day-picker/src/useCalendar.ts
@@ -17,6 +17,7 @@ import { getPreviousMonth } from "./helpers/getPreviousMonth.js";
import { getWeeks } from "./helpers/getWeeks.js";
import { useControlledValue } from "./helpers/useControlledValue.js";
import type { DayPickerProps } from "./types/props.js";
+import type { VisibleMonthsChangeSource } from "./types/shared.js";
/**
* Returns the calendar object used by DayPicker custom components.
@@ -53,13 +54,24 @@ export interface Calendar {
/** Navigate to the specified month. Will fire the `onMonthChange` callback. */
goToMonth: (month: Date) => void;
+ /**
+ * Navigate one displayed month by index. Will fire the
+ * `onVisibleMonthsChange` callback.
+ */
+ goToVisibleMonth: (
+ index: number,
+ month: Date,
+ source: VisibleMonthsChangeSource,
+ ) => Date | undefined;
/**
* Navigate to the month containing the specified day when it falls outside
* the currently displayed calendar.
*
* @param day - The date to navigate to.
*/
- goToDay: (day: CalendarDay) => void;
+ goToDay: (day: CalendarDay, refDay?: CalendarDay) => void;
+ /** Whether the calendar is using the `visibleMonths` display model. */
+ usesVisibleMonths: boolean;
}
/**
@@ -85,10 +97,14 @@ export function useCalendar(
| "reverseMonths"
| "disableNavigation"
| "onMonthChange"
+ | "onVisibleMonthsChange"
| "month"
| "defaultMonth"
+ | "mode"
| "timeZone"
| "broadcastCalendar"
+ | "visibleMonths"
+ | "defaultVisibleMonths"
>,
dateLib: DateLib,
): Calendar {
@@ -101,22 +117,49 @@ export function useCalendar(
// initialMonth is always computed from props.month if provided
props.month ? initialMonth : undefined,
);
+ const usesVisibleMonths =
+ props.visibleMonths !== undefined ||
+ props.defaultVisibleMonths !== undefined;
+ const normalizeVisibleMonths = (
+ months: Date[] | undefined,
+ fallbackMonth: Date,
+ ) => {
+ const sourceMonths = months && months.length > 0 ? months : [fallbackMonth];
+ return sourceMonths.map((month) => startOfMonth(month));
+ };
+ const getInitialVisibleMonths = (fallbackMonth: Date) => {
+ const initialVisibleMonths =
+ props.defaultVisibleMonths ?? props.visibleMonths;
+ return normalizeVisibleMonths(initialVisibleMonths, fallbackMonth);
+ };
+ const controlledVisibleMonths = props.visibleMonths
+ ? normalizeVisibleMonths(props.visibleMonths, initialMonth)
+ : undefined;
+ const [visibleMonths, setVisibleMonths] = useControlledValue(
+ getInitialVisibleMonths(initialMonth),
+ controlledVisibleMonths,
+ );
// biome-ignore lint/correctness/useExhaustiveDependencies: change the initial month when the time zone changes.
useEffect(() => {
const newInitialMonth = getInitialMonth(props, navStart, navEnd, dateLib);
setFirstMonth(newInitialMonth);
+ if (usesVisibleMonths) {
+ setVisibleMonths(getInitialVisibleMonths(newInitialMonth));
+ }
}, [props.timeZone]);
/** The months displayed in the calendar. */
// biome-ignore lint/correctness/useExhaustiveDependencies: We want to recompute only when specific props change.
const { months, weeks, days, previousMonth, nextMonth } = useMemo(() => {
- const displayMonths = getDisplayMonths(
- firstMonth,
- navEnd,
- { numberOfMonths: props.numberOfMonths },
- dateLib,
- );
+ const displayMonths = usesVisibleMonths
+ ? visibleMonths
+ : getDisplayMonths(
+ firstMonth,
+ navEnd,
+ { numberOfMonths: props.numberOfMonths },
+ dateLib,
+ );
const dates = getDates(
displayMonths,
@@ -136,7 +179,7 @@ export function useCalendar(
broadcastCalendar: props.broadcastCalendar,
fixedWeeks: props.fixedWeeks,
ISOWeek: props.ISOWeek,
- reverseMonths: props.reverseMonths,
+ reverseMonths: usesVisibleMonths ? false : props.reverseMonths,
},
dateLib,
);
@@ -145,12 +188,18 @@ export function useCalendar(
const days = getDays(months);
const previousMonth = getPreviousMonth(
- firstMonth,
+ displayMonths[0],
navStart,
- props,
+ usesVisibleMonths ? { ...props, numberOfMonths: 1 } : props,
+ dateLib,
+ );
+ const lastDisplayMonth = displayMonths[displayMonths.length - 1];
+ const nextMonth = getNextMonth(
+ usesVisibleMonths ? lastDisplayMonth : firstMonth,
+ navEnd,
+ usesVisibleMonths ? { ...props, numberOfMonths: 1 } : props,
dateLib,
);
- const nextMonth = getNextMonth(firstMonth, navEnd, props, dateLib);
return {
months,
@@ -162,6 +211,7 @@ export function useCalendar(
}, [
dateLib,
firstMonth.getTime(),
+ visibleMonths.map((month) => month.getTime()).join("-"),
navEnd?.getTime(),
navStart?.getTime(),
props.disableNavigation,
@@ -172,9 +222,10 @@ export function useCalendar(
props.numberOfMonths,
props.pagedNavigation,
props.reverseMonths,
+ usesVisibleMonths,
]);
- const { disableNavigation, onMonthChange } = props;
+ const { disableNavigation, onMonthChange, onVisibleMonthsChange } = props;
const isDayInCalendar = (day: CalendarDay) =>
weeks.some((week: CalendarWeek) => week.days.some((d) => d.isEqualTo(day)));
@@ -196,11 +247,51 @@ export function useCalendar(
onMonthChange?.(newMonth);
};
- const goToDay = (day: CalendarDay) => {
+ const goToVisibleMonth = (
+ index: number,
+ date: Date,
+ source: VisibleMonthsChangeSource,
+ ) => {
+ if (disableNavigation || !usesVisibleMonths) {
+ return undefined;
+ }
+ let newMonth = startOfMonth(date);
+ if (navStart && newMonth < startOfMonth(navStart)) {
+ newMonth = startOfMonth(navStart);
+ }
+ if (navEnd && newMonth > startOfMonth(navEnd)) {
+ newMonth = startOfMonth(navEnd);
+ }
+ const newVisibleMonths = visibleMonths.map((month, monthIndex) =>
+ monthIndex === index ? newMonth : month,
+ );
+ setVisibleMonths(newVisibleMonths);
+ onVisibleMonthsChange?.(newVisibleMonths, {
+ changedIndex: index,
+ month: newMonth,
+ source,
+ });
+ return newMonth;
+ };
+
+ const goToDay = (day: CalendarDay, refDay?: CalendarDay) => {
// is this check necessary?
if (isDayInCalendar(day)) {
return;
}
+ if (usesVisibleMonths) {
+ const visibleMonthIndex = months.findIndex(
+ (month) =>
+ startOfMonth(month.date).getTime() ===
+ startOfMonth(refDay?.displayMonth ?? day.displayMonth).getTime(),
+ );
+ goToVisibleMonth(
+ visibleMonthIndex === -1 ? 0 : visibleMonthIndex,
+ day.date,
+ "keyboard",
+ );
+ return;
+ }
goToMonth(day.date);
};
@@ -216,7 +307,9 @@ export function useCalendar(
nextMonth,
goToMonth,
+ goToVisibleMonth,
goToDay,
+ usesVisibleMonths,
};
return calendar;
diff --git a/packages/react-day-picker/src/useFocus.ts b/packages/react-day-picker/src/useFocus.ts
index 05e66862d1..56f310f7f8 100644
--- a/packages/react-day-picker/src/useFocus.ts
+++ b/packages/react-day-picker/src/useFocus.ts
@@ -89,7 +89,7 @@ export function useFocus(
}
}
- calendar.goToDay(nextFocus);
+ calendar.goToDay(nextFocus, focusedDay);
setFocused(nextFocus);
};