Repository: gpbl/react-day-picker
Pull request #2710 (2025-03-11 21:41:54)

fix(animation): fix issues when navigating months during animation
This PR fixes `DayPicker` minor bugs caused when trying to navigate months while animating the month transition.
In the same PR, improve tests and refactor `useAnimation` implementation.

Issues:
- Navigation month buttons are not clickable during animation.
- Navigating months during animation causes visual bug in the animation of the next month transition.

The videos below compare the `DayPicker` before and after the fix, running the animation at 25% the default speed.

Before

 https://github.com/user-attachments/assets/c2e8c3d4-1626-4d67-ac79-e6676d0d3402

After 

https://github.com/user-attachments/assets/2dd2a25e-11dd-4744-a6cd-dbebdf41df07




Patch:
diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx
index e4eb1ed02..371b780bc 100644
--- a/src/DayPicker.tsx
+++ b/src/DayPicker.tsx
@@ -303,6 +303,7 @@ export function DayPicker(props: DayPickerProps) {
         >
           {!props.hideNavigation && (
             <components.Nav
+              data-animated-nav={props.animate ? "true" : undefined}
               className={classNames[UI.Nav]}
               style={styles?.[UI.Nav]}
               aria-label={labelNav()}
diff --git a/src/useAnimation.ts b/src/useAnimation.ts
index e0fd45f2f..66028f585 100644
--- a/src/useAnimation.ts
+++ b/src/useAnimation.ts
@@ -6,6 +6,25 @@ import { CalendarMonth } from "./classes/CalendarMonth.js";
 import type { DateLib } from "./classes/DateLib.js";
 import { ClassNames } from "./types/shared.js";
 
+const asHtmlElement = (element: Element | null): HTMLElement | null => {
+  if (element instanceof HTMLElement) return element;
+  return null;
+};
+
+const queryMonthEls = (element: HTMLElement) => [
+  ...(element.querySelectorAll("[data-animated-month]") ?? [])
+];
+const queryMonthEl = (element: HTMLElement) =>
+  asHtmlElement(element.querySelector("[data-animated-month]"));
+const queryCaptionEl = (element: HTMLElement) =>
+  asHtmlElement(element.querySelector("[data-animated-caption]"));
+const queryWeeksEl = (element: HTMLElement) =>
+  asHtmlElement(element.querySelector("[data-animated-weeks]"));
+const queryNavEl = (element: HTMLElement) =>
+  asHtmlElement(element.querySelector("[data-animated-nav]"));
+const queryWeekdaysEl = (element: HTMLElement) =>
+  asHtmlElement(element.querySelector("[data-animated-weekdays]"));
+
 /** @private */
 export function useAnimation(
   rootElRef: React.RefObject<HTMLDivElement | null>,
@@ -36,31 +55,64 @@ export function useAnimation(
       !enabled ||
       !rootElRef.current ||
       // safety check because the ref can be set to anything by consumers
-      !(rootElRef.current instanceof HTMLElement)
+      !(rootElRef.current instanceof HTMLElement) ||
+      // validation required for the animation to work as expected
+      months.length === 0 ||
+      previousMonths.length === 0 ||
+      months.length !== previousMonths.length
     ) {
       return;
     }
 
+    const isSameMonth = dateLib.isSameMonth(
+      months[0].date,
+      previousMonths[0].date
+    );
+
+    const isAfterPreviousMonth = dateLib.isAfter(
+      months[0].date,
+      previousMonths[0].date
+    );
+
+    const captionAnimationClass = isAfterPreviousMonth
+      ? classNames[Animation.caption_after_enter]
+      : classNames[Animation.caption_before_enter];
+
+    const weeksAnimationClass = isAfterPreviousMonth
+      ? classNames[Animation.weeks_after_enter]
+      : classNames[Animation.weeks_before_enter];
+
     // get previous root element snapshot before updating the snapshot ref
     const previousRootElSnapshot = previousRootElSnapshotRef.current;
 
     // update snapshot for next effect trigger
     const rootElSnapshot = rootElRef.current.cloneNode(true);
     if (rootElSnapshot instanceof HTMLElement) {
-      // if this effect is triggered while animating, we need to remove the old month snapshots from the new root snapshot
-      const currentMonthElsSnapshot = [
-        ...(rootElSnapshot.querySelectorAll(`[data-animated-month]`) ?? [])
-      ];
+      // if this effect is triggered while animating, we need to clean up the new root snapshot
+      // to put it in the same state as when not animating, to correctly animate the next month change
+      const currentMonthElsSnapshot = queryMonthEls(rootElSnapshot);
       currentMonthElsSnapshot.forEach((currentMonthElSnapshot) => {
-        const previousMonthElSnapshot = currentMonthElSnapshot.querySelector(
-          `[data-animated-month]`
-        );
+        if (!(currentMonthElSnapshot instanceof HTMLElement)) return;
+
+        // remove the old month snapshots from the new root snapshot
+        const previousMonthElSnapshot = queryMonthEl(currentMonthElSnapshot);
         if (
           previousMonthElSnapshot &&
           currentMonthElSnapshot.contains(previousMonthElSnapshot)
         ) {
           currentMonthElSnapshot.removeChild(previousMonthElSnapshot);
         }
+
+        // remove animation classes from the new month snapshots
+        const captionEl = queryCaptionEl(currentMonthElSnapshot);
+        if (captionEl) {
+          captionEl.classList.remove(captionAnimationClass);
+        }
+
+        const weeksEl = queryWeeksEl(currentMonthElSnapshot);
+        if (weeksEl) {
+          weeksEl.classList.remove(weeksAnimationClass);
+        }
       });
 
       previousRootElSnapshotRef.current = rootElSnapshot;
@@ -68,35 +120,21 @@ export function useAnimation(
       previousRootElSnapshotRef.current = null;
     }
 
-    // validation required for the animation to work as expected
     if (
-      months.length === 0 ||
-      previousMonths.length === 0 ||
-      months.length !== previousMonths.length ||
+      animatingRef.current ||
+      isSameMonth ||
       // skip animation if a day is focused because it can cause issues to the animation and is better for a11y
-      focused ||
-      animatingRef.current
+      focused
     ) {
       return;
     }
 
-    const isSameMonth = dateLib.isSameMonth(
-      months[0].date,
-      previousMonths[0].date
-    );
+    const previousMonthEls =
+      previousRootElSnapshot instanceof HTMLElement
+        ? queryMonthEls(previousRootElSnapshot)
+        : [];
 
-    if (isSameMonth) {
-      return;
-    }
-
-    const previousMonthEls = [
-      ...(previousRootElSnapshot?.querySelectorAll(`[data-animated-month]`) ??
-        [])
-    ];
-
-    const currentMonthEls = [
-      ...(rootElRef.current.querySelectorAll(`[data-animated-month]`) ?? [])
-    ];
+    const currentMonthEls = queryMonthEls(rootElRef.current);
 
     if (
       currentMonthEls &&
@@ -107,10 +145,13 @@ export function useAnimation(
       animatingRef.current = true;
       const cleanUpFunctions: (() => void)[] = [];
 
-      const isAfterPreviousMonth = dateLib.isAfter(
-        months[0].date,
-        previousMonths[0].date
-      );
+      // set isolation to isolate to isolate the stacking context during animation
+      rootElRef.current.style.isolation = "isolate";
+      // set z-index to 1 to ensure the nav is clickable over the other elements being animated
+      const navEl = queryNavEl(rootElRef.current);
+      if (navEl) {
+        navEl.style.zIndex = "1";
+      }
 
       currentMonthEls.forEach((currentMonthEl, index) => {
         const previousMonthEl = previousMonthEls[index];
@@ -120,35 +161,33 @@ export function useAnimation(
         }
 
         // animate new displayed month
-        const captionAnimationClass = isAfterPreviousMonth
-          ? classNames[Animation.caption_after_enter]
-          : classNames[Animation.caption_before_enter];
-
-        const weeksAnimationClass = isAfterPreviousMonth
-          ? classNames[Animation.weeks_after_enter]
-          : classNames[Animation.weeks_before_enter];
-
         currentMonthEl.style.position = "relative";
         currentMonthEl.style.overflow = "hidden";
-        const captionEl = currentMonthEl.querySelector(
-          `[data-animated-caption]`
-        );
-        if (captionEl && captionEl instanceof HTMLElement) {
+        const captionEl = queryCaptionEl(currentMonthEl);
+        if (captionEl) {
           captionEl.classList.add(captionAnimationClass);
         }
 
-        const weeksEl = currentMonthEl.querySelector(`[data-animated-weeks]`);
-        if (weeksEl && weeksEl instanceof HTMLElement) {
+        const weeksEl = queryWeeksEl(currentMonthEl);
+        if (weeksEl) {
           weeksEl.classList.add(weeksAnimationClass);
         }
         // animate new displayed month end
 
         const cleanUp = () => {
           animatingRef.current = false;
-          if (captionEl && captionEl instanceof HTMLElement) {
+
+          if (rootElRef.current) {
+            rootElRef.current.style.isolation = "";
+          }
+          if (navEl) {
+            navEl.style.zIndex = "";
+          }
+
+          if (captionEl) {
             captionEl.classList.remove(captionAnimationClass);
           }
-          if (weeksEl && weeksEl instanceof HTMLElement) {
+          if (weeksEl) {
             weeksEl.classList.remove(weeksAnimationClass);
           }
           currentMonthEl.style.position = "";
@@ -166,17 +205,13 @@ export function useAnimation(
         previousMonthEl.setAttribute("aria-hidden", "true");
 
         // hide the weekdays container of the old month and only the new one
-        const previousWeekdaysEl = previousMonthEl.querySelector(
-          `[data-animated-weekdays]`
-        );
-        if (previousWeekdaysEl && previousWeekdaysEl instanceof HTMLElement) {
+        const previousWeekdaysEl = queryWeekdaysEl(previousMonthEl);
+        if (previousWeekdaysEl) {
           previousWeekdaysEl.style.opacity = "0";
         }
 
-        const previousCaptionEl = previousMonthEl.querySelector(
-          `[data-animated-caption]`
-        );
-        if (previousCaptionEl && previousCaptionEl instanceof HTMLElement) {
+        const previousCaptionEl = queryCaptionEl(previousMonthEl);
+        if (previousCaptionEl) {
           previousCaptionEl.classList.add(
             isAfterPreviousMonth
               ? classNames[Animation.caption_before_exit]
@@ -185,10 +220,8 @@ export function useAnimation(
           previousCaptionEl.addEventListener("animationend", cleanUp);
         }
 
-        const previousWeeksEl = previousMonthEl.querySelector(
-          `[data-animated-weeks]`
-        );
-        if (previousWeeksEl && previousWeeksEl instanceof HTMLElement) {
+        const previousWeeksEl = queryWeeksEl(previousMonthEl);
+        if (previousWeeksEl) {
           previousWeeksEl.classList.add(
             isAfterPreviousMonth
               ? classNames[Animation.weeks_before_exit]


Test patch:
diff --git a/src/useAnimation.test.tsx b/src/useAnimation.test.tsx
index a83d49fdf..8269723d8 100644
--- a/src/useAnimation.test.tsx
+++ b/src/useAnimation.test.tsx
@@ -10,6 +10,10 @@ import { DayPicker } from "./DayPicker";
 
 jest.setSystemTime(new Date(2025, 1, 10));
 
+const getRootContainer = () => document.querySelector(`.rdp-root`);
+const getNavContainers = () => [
+  ...document.querySelectorAll(`[data-animated-nav]`)
+];
 const getMonthContainers = () => [
   ...document.querySelectorAll(`[data-animated-month]`)
 ];
@@ -70,6 +74,30 @@ describe("useAnimation", () => {
       expect(getMonthCaptionContainers()[1]).toHaveTextContent("April 2025");
     });
 
+    it("should handle month changes during animation to correctly animate the next month change", async () => {
+      render(<DayPicker animate={true} />);
+      await user.click(nextButton());
+      await user.click(nextButton());
+
+      const animationEndEvent = new Event("animationend");
+      getMonthCaptionContainers()[0].dispatchEvent(animationEndEvent);
+
+      await user.click(nextButton());
+
+      expect(getMonthCaptionContainers()[0]).toHaveTextContent("April 2025");
+      expect(getMonthCaptionContainers()[1]).toHaveTextContent("May 2025");
+      expect(getMonthContainers()).toHaveLength(2);
+      expect(getMonthCaptionContainers()).toHaveLength(2);
+      expect(getMonthWeekdaysContainers()).toHaveLength(2);
+      expect(getMonthWeeksContainers()).toHaveLength(2);
+      expect(getMonthCaptionContainers()[0]).not.toHaveClass(
+        "rdp-caption_after_enter"
+      );
+      expect(getMonthWeeksContainers()[0]).not.toHaveClass(
+        "rdp-weeks_after_enter"
+      );
+    });
+
     it("should apply the correct animation class when entering month is after the exiting month", async () => {
       render(<DayPicker animate={true} />);
 
@@ -109,26 +137,54 @@ describe("useAnimation", () => {
 
       await user.click(nextButton());
 
-      expect(getMonthContainers()).toHaveLength(2);
-      expect(getMonthCaptionContainers()).toHaveLength(2);
-      expect(getMonthWeekdaysContainers()).toHaveLength(2);
-      expect(getMonthWeeksContainers()).toHaveLength(2);
+      let navContainers = getNavContainers();
+      let monthContainers = getMonthContainers();
+      let monthCaptionContainers = getMonthCaptionContainers();
+      let monthWeekdaysContainers = getMonthWeekdaysContainers();
+      let monthWeeksContainers = getMonthWeeksContainers();
+
+      expect(navContainers).toHaveLength(1);
+      expect(monthContainers).toHaveLength(2);
+      expect(monthCaptionContainers).toHaveLength(2);
+      expect(monthWeekdaysContainers).toHaveLength(2);
+      expect(monthWeeksContainers).toHaveLength(2);
+
+      expect(getRootContainer()).toHaveStyle("isolation: isolate");
+      expect(navContainers[0]).toHaveStyle("z-index: 1");
+      expect(monthContainers[0]).toHaveStyle("position: relative");
+      expect(monthContainers[0]).toHaveStyle("overflow: hidden");
+      expect(monthContainers[1]).toHaveStyle("overflow: hidden");
+      expect(monthContainers[1]).toHaveStyle("pointer-events: none");
+      expect(monthContainers[1]).toHaveStyle("position: absolute");
+      expect(monthContainers[1]).toHaveAttribute("aria-hidden", "true");
+      expect(monthWeekdaysContainers[0]).toHaveStyle("opacity: 0");
+      expect(monthCaptionContainers[1]).toHaveClass("rdp-caption_after_enter");
+      expect(monthWeeksContainers[1]).toHaveClass("rdp-weeks_after_enter");
 
       const animationEndEvent = new Event("animationend");
       getMonthCaptionContainers()[0].dispatchEvent(animationEndEvent);
 
-      expect(getMonthContainers()).toHaveLength(1);
-      expect(getMonthCaptionContainers()).toHaveLength(1);
-      expect(getMonthWeekdaysContainers()).toHaveLength(1);
-      expect(getMonthWeeksContainers()).toHaveLength(1);
-
-      expect(getMonthCaptionContainers()[0]).not.toHaveClass(
+      navContainers = getNavContainers();
+      monthContainers = getMonthContainers();
+      monthCaptionContainers = getMonthCaptionContainers();
+      monthWeekdaysContainers = getMonthWeekdaysContainers();
+      monthWeeksContainers = getMonthWeeksContainers();
+
+      expect(navContainers).toHaveLength(1);
+      expect(navContainers).toHaveLength(1);
+      expect(monthContainers).toHaveLength(1);
+      expect(monthCaptionContainers).toHaveLength(1);
+      expect(monthWeekdaysContainers).toHaveLength(1);
+      expect(monthWeeksContainers).toHaveLength(1);
+
+      expect(getRootContainer()).not.toHaveStyle("isolation: isolate");
+      expect(navContainers[0]).not.toHaveStyle("z-index: 1");
+      expect(monthContainers[0]).not.toHaveStyle("position: relative");
+      expect(monthContainers[0]).not.toHaveStyle("overflow: hidden");
+      expect(monthCaptionContainers[0]).not.toHaveClass(
         "rdp-caption_after_enter"
       );
-
-      expect(getMonthWeeksContainers()[0]).not.toHaveClass(
-        "rdp-weeks_after_enter"
-      );
+      expect(monthWeeksContainers[0]).not.toHaveClass("rdp-weeks_after_enter");
     });
   });
 });