Repository: gpbl/react-day-picker
Pull request #2684 (2025-02-16 14:40:56)

feat: month animation
Idea from the board:
https://github.com/users/gpbl/projects/3/views/1?pane=issue&itemId=9360383

## Description

Add a feature to animate the calendar changing the displayed month. The feature can be enabled by setting the prop `animate` to `true`.

The core idea in the approach of this implementation is to imperatively manipulate the DOM when the displayed months change, because doing it with only React APIs is very convoluted and has bad performance.
All the DOM manipulation happens inside a `useLayoutEffect` to set up the animation right before the browser renders.

There is a new set of CSS classes that are imperatively applied to the DOM to trigger the animations that are based on the calendar changing to a month that is after the previous month or before it because depending on this condition, the calendar weeks should animate left or right, and is inverted when the direction is `rtl`.

The animation is disabled when navigating the calendar with the keyboard arrows because focusing on the month being animated can cause issues with the animation. Animation can be worse for a11y, therefore disabling for keyboard navigation seems better.

## Demo

https://github.com/user-attachments/assets/d7173390-9f4c-4861-a1e7-802e48f43c54

https://github.com/user-attachments/assets/4579b9e5-6b65-4940-9e0b-48d0055ff0e5

https://github.com/user-attachments/assets/83d7d28e-ceb1-4e39-93d3-6ce2fa4d90e0




Patch:
diff --git a/examples/Animate.tsx b/examples/Animate.tsx
new file mode 100644
index 000000000..8b01cce1c
--- /dev/null
+++ b/examples/Animate.tsx
@@ -0,0 +1,19 @@
+import React, { useState } from "react";
+
+import { DayPicker } from "react-day-picker";
+
+export function Animate() {
+  const [selected, setSelected] = useState<Date>();
+
+  return (
+    <DayPicker
+      mode="single"
+      animate
+      selected={selected}
+      onSelect={setSelected}
+      footer={
+        selected ? `Selected: ${selected.toLocaleDateString()}` : "Pick a day."
+      }
+    />
+  );
+}
diff --git a/examples/AnimateRange.tsx b/examples/AnimateRange.tsx
new file mode 100644
index 000000000..0179cc7ea
--- /dev/null
+++ b/examples/AnimateRange.tsx
@@ -0,0 +1,17 @@
+import React, { useState } from "react";
+
+import { DateRange, DayPicker } from "react-day-picker";
+
+export function AnimateRange() {
+  const [selected, setSelected] = useState<DateRange>();
+
+  return (
+    <DayPicker
+      mode="range"
+      animate
+      selected={selected}
+      onSelect={setSelected}
+      numberOfMonths={6}
+    />
+  );
+}
diff --git a/examples/AnimateRtl.tsx b/examples/AnimateRtl.tsx
new file mode 100644
index 000000000..00dc0e87b
--- /dev/null
+++ b/examples/AnimateRtl.tsx
@@ -0,0 +1,22 @@
+import React, { useState } from "react";
+
+import { DayPicker } from "react-day-picker";
+import classNames from "react-day-picker/style.module.css";
+
+export function AnimateRtl() {
+  const [selected, setSelected] = useState<Date>();
+
+  return (
+    <DayPicker
+      mode="single"
+      animate
+      dir="rtl"
+      selected={selected}
+      onSelect={setSelected}
+      classNames={classNames}
+      footer={
+        selected ? `Selected: ${selected.toLocaleDateString()}` : "Pick a day."
+      }
+    />
+  );
+}
diff --git a/examples/index.ts b/examples/index.ts
index e3be6bd66..332127232 100644
--- a/examples/index.ts
+++ b/examples/index.ts
@@ -1,4 +1,7 @@
 export * from "./AccessibleDatePicker";
+export * from "./Animate";
+export * from "./AnimateRange";
+export * from "./AnimateRtl";
 export * from "./AutoFocus";
 export * from "./BroadcastCalendar";
 export * from "./ContainerAttributes";
diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx
index c581c2ffc..b47f8aaf1 100644
--- a/src/DayPicker.tsx
+++ b/src/DayPicker.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo } from "react";
+import React, { useCallback, useMemo, useRef } from "react";
 import type { MouseEvent, FocusEvent, KeyboardEvent, ChangeEvent } from "react";
 
 import { UI, DayFlag, SelectionState } from "./UI.js";
@@ -22,6 +22,7 @@ import type {
   SelectedValue,
   SelectHandler
 } from "./types/index.js";
+import { useAnimation } from "./useAnimation.js";
 import { useCalendar } from "./useCalendar.js";
 import { type DayPickerContext, dayPickerContext } from "./useDayPicker.js";
 import { useFocus } from "./useFocus.js";
@@ -256,6 +257,14 @@ export function DayPicker(props: DayPickerProps) {
 
   const dataAttributes = getDataAttributes(props);
 
+  const rootElRef = useRef<HTMLDivElement>(null);
+  useAnimation(rootElRef, Boolean(props.animate), {
+    classNames,
+    months,
+    focused,
+    dateLib
+  });
+
   const contextValue: DayPickerContext<DayPickerProps> = {
     dayPickerProps: props,
     selected: selectedValue as SelectedValue<DayPickerProps>,
@@ -276,6 +285,7 @@ export function DayPicker(props: DayPickerProps) {
   return (
     <dayPickerContext.Provider value={contextValue}>
       <components.Root
+        rootRef={props.animate ? rootElRef : undefined}
         className={className}
         style={style}
         dir={props.dir}
@@ -317,9 +327,9 @@ export function DayPicker(props: DayPickerProps) {
               formatters,
               dateLib
             );
-
             return (
               <components.Month
+                data-animated-month={props.animate ? "true" : undefined}
                 className={classNames[UI.Month]}
                 style={styles?.[UI.Month]}
                 key={displayIndex}
@@ -327,6 +337,7 @@ export function DayPicker(props: DayPickerProps) {
                 calendarMonth={calendarMonth}
               >
                 <components.MonthCaption
+                  data-animated-caption={props.animate ? "true" : undefined}
                   className={classNames[UI.MonthCaption]}
                   style={styles?.[UI.MonthCaption]}
                   calendarMonth={calendarMonth}
@@ -400,6 +411,9 @@ export function DayPicker(props: DayPickerProps) {
                 >
                   {!props.hideWeekdays && (
                     <components.Weekdays
+                      data-animated-weekdays={
+                        props.animate ? "true" : undefined
+                      }
                       className={classNames[UI.Weekdays]}
                       style={styles?.[UI.Weekdays]}
                     >
@@ -431,6 +445,7 @@ export function DayPicker(props: DayPickerProps) {
                     </components.Weekdays>
                   )}
                   <components.Weeks
+                    data-animated-weeks={props.animate ? "true" : undefined}
                     className={classNames[UI.Weeks]}
                     style={styles?.[UI.Weeks]}
                   >
diff --git a/src/UI.ts b/src/UI.ts
index 250d82663..3535ca07e 100644
--- a/src/UI.ts
+++ b/src/UI.ts
@@ -100,6 +100,26 @@ export enum SelectionState {
   selected = "selected"
 }
 
+/** CSS classes used for animating months and captions. */
+export enum Animation {
+  /** Applied to the entering month when it is after the exiting month. */
+  weeks_next_enter = "weeks_next_enter",
+  /** Applied to the entering month when it is before the exiting month. */
+  weeks_prev_enter = "weeks_prev_enter",
+  /** Applied to the exiting month when it is after the entering month. */
+  weeks_next_exit = "weeks_next_exit",
+  /** Applied to the exiting month when it is before the entering month. */
+  weeks_prev_exit = "weeks_prev_exit",
+  /** Applied to the entering month caption when it is after the exiting month. */
+  caption_next_enter = "caption_next_enter",
+  /** Applied to the entering month caption when it is before the exiting month. */
+  caption_prev_enter = "caption_prev_enter",
+  /** Applied to the exiting month caption when it is after the entering month. */
+  caption_next_exit = "caption_next_exit",
+  /** Applied to the exiting month caption when it is before the entering month. */
+  caption_prev_exit = "caption_prev_exit"
+}
+
 /**
  * Deprecated UI elements and flags.
  *
diff --git a/src/components/Root.tsx b/src/components/Root.tsx
index f49a7da4b..202056032 100644
--- a/src/components/Root.tsx
+++ b/src/components/Root.tsx
@@ -1,4 +1,4 @@
-import React, { type HTMLAttributes } from "react";
+import React, { type Ref, type HTMLAttributes } from "react";
 
 /**
  * Render the root element.
@@ -6,8 +6,14 @@ import React, { type HTMLAttributes } from "react";
  * @group Components
  * @see https://daypicker.dev/guides/custom-components
  */
-export function Root(props: HTMLAttributes<HTMLDivElement>) {
-  return <div {...props} />;
+export function Root(
+  props: {
+    /** Ref for root element used when `animate` is `true`. */
+    rootRef?: Ref<HTMLDivElement>;
+  } & HTMLAttributes<HTMLDivElement>
+) {
+  const { rootRef, ...rest } = props;
+  return <div {...rest} ref={rootRef} />;
 }
 
 export type RootProps = Parameters<typeof Root>[0];
diff --git a/src/helpers/getDefaultClassNames.ts b/src/helpers/getDefaultClassNames.ts
index 4f0725790..64bbc9e23 100644
--- a/src/helpers/getDefaultClassNames.ts
+++ b/src/helpers/getDefaultClassNames.ts
@@ -1,4 +1,4 @@
-import { UI, DayFlag, SelectionState } from "../UI.js";
+import { UI, DayFlag, SelectionState, Animation } from "../UI.js";
 import type { ClassNames } from "../types/index.js";
 
 /**
@@ -24,5 +24,10 @@ export function getDefaultClassNames(): ClassNames {
       `rdp-${SelectionState[key as keyof typeof SelectionState]}`;
   }
 
+  for (const key in Animation) {
+    classNames[Animation[key as keyof typeof Animation]] =
+      `rdp-${Animation[key as keyof typeof Animation]}`;
+  }
+
   return classNames as Required<ClassNames>;
 }
diff --git a/src/style.css b/src/style.css
index ca8b80d87..842f876bf 100644
--- a/src/style.css
+++ b/src/style.css
@@ -50,6 +50,9 @@
   --rdp-weekday-text-align: center; /* The text alignment of the weekday cells. */
 
   --rdp-gradient-direction: 90deg;
+
+  --rdp-animation_duration: 0.3s;
+  --rdp-animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
 }
 
 .rdp-root[dir="rtl"] {
@@ -146,10 +149,6 @@
   fill: var(--rdp-accent-color);
 }
 
-.rdp-root[dir="rtl"] .rdp-nav .rdp-chevron {
-  transform: rotate(180deg);
-}
-
 .rdp-root[dir="rtl"] .rdp-nav .rdp-chevron {
   transform: rotate(180deg);
   transform-origin: 50%;
@@ -304,3 +303,117 @@
 .rdp-focusable {
   cursor: pointer;
 }
+
+@keyframes rdp-slide_in_left {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(0);
+  }
+}
+
+@keyframes rdp-slide_in_right {
+  0% {
+    transform: translateX(100%);
+  }
+  100% {
+    transform: translateX(0);
+  }
+}
+
+@keyframes rdp-slide_out_left {
+  0% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(-100%);
+  }
+}
+
+@keyframes rdp-slide_out_right {
+  0% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+.rdp-weeks_next_enter {
+  animation: rdp-slide_in_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+.rdp-weeks_prev_exit {
+  animation: rdp-slide_out_left
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+.rdp-weeks_prev_enter {
+  animation: rdp-slide_in_left var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.rdp-weeks_next_exit {
+  animation: rdp-slide_out_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .rdp-weeks_next_enter {
+  animation: rdp-slide_in_left var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .rdp-weeks_prev_exit {
+  animation: rdp-slide_out_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .rdp-weeks_prev_enter {
+  animation: rdp-slide_in_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .rdp-weeks_next_exit {
+  animation: rdp-slide_out_left
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+@keyframes rdp-animation_fade_in_keyframes {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@keyframes rdp-animation_fade_out_keyframes {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+
+.rdp-caption_next_enter {
+  animation: rdp-animation_fade_in_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.rdp-caption_prev_exit {
+  animation: rdp-animation_fade_out_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.rdp-caption_prev_enter {
+  animation: rdp-animation_fade_in_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.rdp-caption_next_exit {
+  animation: rdp-animation_fade_out_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
diff --git a/src/style.module.css b/src/style.module.css
index 8d9096830..1e9800845 100644
--- a/src/style.module.css
+++ b/src/style.module.css
@@ -50,6 +50,9 @@
   --rdp-weekday-text-align: center; /* The text alignment of the weekday cells. */
 
   --rdp-gradient-direction: 90deg;
+
+  --rdp-animation_duration: 0.3s;
+  --rdp-animation_timing: cubic-bezier(0.4, 0, 0.2, 1);
 }
 
 .root[dir="rtl"] {
@@ -146,10 +149,6 @@
   fill: var(--rdp-accent-color);
 }
 
-.root[dir="rtl"] .nav .chevron {
-  transform: rotate(180deg);
-}
-
 .root[dir="rtl"] .nav .chevron {
   transform: rotate(180deg);
   transform-origin: 50%;
@@ -304,3 +303,117 @@
 .focusable {
   cursor: pointer;
 }
+
+@keyframes rdp-slide_in_left {
+  0% {
+    transform: translateX(-100%);
+  }
+  100% {
+    transform: translateX(0);
+  }
+}
+
+@keyframes rdp-slide_in_right {
+  0% {
+    transform: translateX(100%);
+  }
+  100% {
+    transform: translateX(0);
+  }
+}
+
+@keyframes rdp-slide_out_left {
+  0% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(-100%);
+  }
+}
+
+@keyframes rdp-slide_out_right {
+  0% {
+    transform: translateX(0);
+  }
+  100% {
+    transform: translateX(100%);
+  }
+}
+
+.weeks_next_enter {
+  animation: rdp-slide_in_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+.weeks_prev_exit {
+  animation: rdp-slide_out_left
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+.weeks_prev_enter {
+  animation: rdp-slide_in_left var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.weeks_next_exit {
+  animation: rdp-slide_out_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .weeks_next_enter {
+  animation: rdp-slide_in_left var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .weeks_prev_exit {
+  animation: rdp-slide_out_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .weeks_prev_enter {
+  animation: rdp-slide_in_right
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+[dir="rtl"] .weeks_next_exit {
+  animation: rdp-slide_out_left
+    var(--rdp-animation_duration) var(--rdp-animation_timing) forwards;
+}
+
+@keyframes rdp-animation_fade_in_keyframes {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+
+@keyframes rdp-animation_fade_out_keyframes {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
+
+.caption_next_enter {
+  animation: rdp-animation_fade_in_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.caption_prev_exit {
+  animation: rdp-animation_fade_out_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.caption_prev_enter {
+  animation: rdp-animation_fade_in_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
+
+.caption_next_exit {
+  animation: rdp-animation_fade_out_keyframes var(--rdp-animation_duration)
+    var(--rdp-animation_timing) forwards;
+}
diff --git a/src/types/props.ts b/src/types/props.ts
index e9d8496f0..cd3fb2931 100644
--- a/src/types/props.ts
+++ b/src/types/props.ts
@@ -245,6 +245,12 @@ export interface PropsBase {
    * @see https://daypicker.dev/docs/customization#showweeknumber
    */
   showWeekNumber?: boolean;
+  /**
+   * Animate navigating between months.
+   *
+   * @see https://daypicker.dev/docs/customization#animation
+   */
+  animate?: boolean;
   /**
    * Display the weeks in the month following the broadcast calendar. Setting
    * this prop will ignore {@link weekStartsOn} (always Monday) and
diff --git a/src/types/shared.ts b/src/types/shared.ts
index be3cae5e5..2522b460e 100644
--- a/src/types/shared.ts
+++ b/src/types/shared.ts
@@ -1,6 +1,6 @@
 import type { CSSProperties } from "react";
 
-import { UI, DayFlag, SelectionState } from "../UI.js";
+import { UI, DayFlag, SelectionState, Animation } from "../UI.js";
 import * as components from "../components/custom-components.js";
 import {
   formatCaption,
@@ -316,7 +316,7 @@ export type MonthChangeEventHandler = (month: Date) => void;
  *   };
  */
 export type ClassNames = {
-  [key in UI | SelectionState | DayFlag]: string;
+  [key in UI | SelectionState | DayFlag | Animation]: string;
 };
 
 /**
diff --git a/src/useAnimation.ts b/src/useAnimation.ts
new file mode 100644
index 000000000..1f16163f5
--- /dev/null
+++ b/src/useAnimation.ts
@@ -0,0 +1,203 @@
+import React, { useLayoutEffect, useRef } from "react";
+
+import { Animation } from "./UI.js";
+import type { CalendarDay } from "./classes/CalendarDay.js";
+import { CalendarMonth } from "./classes/CalendarMonth.js";
+import type { DateLib } from "./classes/DateLib.js";
+import { ClassNames } from "./types/shared.js";
+
+/** @private */
+export function useAnimation(
+  rootElRef: React.RefObject<HTMLDivElement | null>,
+  enabled: boolean,
+  {
+    classNames,
+    months,
+    focused,
+    dateLib
+  }: {
+    classNames: ClassNames;
+    months: CalendarMonth[];
+    focused: CalendarDay | undefined;
+    dateLib: DateLib;
+  }
+): void {
+  const previousRootElSnapshotRef = useRef<HTMLElement>(null);
+  const previousMonthsRef = useRef(months);
+  const animatingRef = useRef(false);
+
+  useLayoutEffect(() => {
+    // get previous months before updating the previous months ref
+    const previousMonths = previousMonthsRef.current;
+    // update previous months ref for next effect trigger
+    previousMonthsRef.current = months;
+
+    if (
+      !enabled ||
+      !rootElRef.current ||
+      // safety check because the ref can be set to anything by consumers
+      !(rootElRef.current instanceof HTMLElement)
+    ) {
+      return;
+    }
+
+    // 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]`) ?? [])
+      ];
+      currentMonthElsSnapshot.forEach((currentMonthElSnapshot) => {
+        const previousMonthElSnapshot = currentMonthElSnapshot.querySelector(
+          `[data-animated-month]`
+        );
+        if (
+          previousMonthElSnapshot &&
+          currentMonthElSnapshot.contains(previousMonthElSnapshot)
+        ) {
+          currentMonthElSnapshot.removeChild(previousMonthElSnapshot);
+        }
+      });
+
+      previousRootElSnapshotRef.current = rootElSnapshot;
+    } else {
+      previousRootElSnapshotRef.current = null;
+    }
+
+    // validation required for the animation to work as expected
+    if (
+      months.length === 0 ||
+      previousMonths.length === 0 ||
+      months.length !== previousMonths.length ||
+      // skip animation if a day is focused because it can cause issues to the animation and is better for a11y
+      focused ||
+      animatingRef.current
+    ) {
+      return;
+    }
+
+    const isSameMonth = dateLib.isSameMonth(
+      months[0].date,
+      previousMonths[0].date
+    );
+
+    if (isSameMonth) {
+      return;
+    }
+
+    const previousMonthEls = [
+      ...(previousRootElSnapshot?.querySelectorAll(`[data-animated-month]`) ??
+        [])
+    ];
+
+    const currentMonthEls = [
+      ...(rootElRef.current.querySelectorAll(`[data-animated-month]`) ?? [])
+    ];
+
+    if (
+      currentMonthEls &&
+      currentMonthEls.every((el) => el instanceof HTMLElement) &&
+      previousMonthEls &&
+      previousMonthEls.every((el) => el instanceof HTMLElement)
+    ) {
+      animatingRef.current = true;
+      const cleanUpFunctions: (() => void)[] = [];
+
+      const isAfterPreviousMonth = dateLib.isAfter(
+        months[0].date,
+        previousMonths[0].date
+      );
+
+      currentMonthEls.forEach((currentMonthEl, index) => {
+        const previousMonthEl = previousMonthEls[index];
+
+        if (!previousMonthEl) {
+          return;
+        }
+
+        // animate new displayed month
+        const captionAnimationClass = isAfterPreviousMonth
+          ? classNames[Animation.caption_next_enter]
+          : classNames[Animation.caption_prev_enter];
+
+        const weeksAnimationClass = isAfterPreviousMonth
+          ? classNames[Animation.weeks_next_enter]
+          : classNames[Animation.weeks_prev_enter];
+
+        currentMonthEl.style.position = "relative";
+        currentMonthEl.style.overflow = "hidden";
+        const captionEl = currentMonthEl.querySelector(
+          `[data-animated-caption]`
+        );
+        if (captionEl && captionEl instanceof HTMLElement) {
+          captionEl.classList.add(captionAnimationClass);
+        }
+
+        const weeksEl = currentMonthEl.querySelector(`[data-animated-weeks]`);
+        if (weeksEl && weeksEl instanceof HTMLElement) {
+          weeksEl.classList.add(weeksAnimationClass);
+        }
+        // animate new displayed month end
+
+        const cleanUp = () => {
+          animatingRef.current = false;
+          if (captionEl && captionEl instanceof HTMLElement) {
+            captionEl.classList.remove(captionAnimationClass);
+          }
+          if (weeksEl && weeksEl instanceof HTMLElement) {
+            weeksEl.classList.remove(weeksAnimationClass);
+          }
+          currentMonthEl.style.position = "";
+          currentMonthEl.style.overflow = "";
+          if (currentMonthEl.contains(previousMonthEl)) {
+            currentMonthEl.removeChild(previousMonthEl);
+          }
+        };
+        cleanUpFunctions.push(cleanUp);
+
+        // animate old displayed month
+        previousMonthEl.style.pointerEvents = "none";
+        previousMonthEl.style.position = "absolute";
+        previousMonthEl.style.overflow = "hidden";
+        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) {
+          previousWeekdaysEl.style.opacity = "0";
+        }
+
+        const previousCaptionEl = previousMonthEl.querySelector(
+          `[data-animated-caption]`
+        );
+        if (previousCaptionEl && previousCaptionEl instanceof HTMLElement) {
+          previousCaptionEl.classList.add(
+            isAfterPreviousMonth
+              ? classNames[Animation.caption_prev_exit]
+              : classNames[Animation.caption_next_exit]
+          );
+          previousCaptionEl.addEventListener("animationend", cleanUp);
+        }
+
+        const previousWeeksEl = previousMonthEl.querySelector(
+          `[data-animated-weeks]`
+        );
+        if (previousWeeksEl && previousWeeksEl instanceof HTMLElement) {
+          previousWeeksEl.classList.add(
+            isAfterPreviousMonth
+              ? classNames[Animation.weeks_prev_exit]
+              : classNames[Animation.weeks_next_exit]
+          );
+        }
+
+        currentMonthEl.insertBefore(previousMonthEl, currentMonthEl.firstChild);
+      });
+    }
+  });
+}


Test patch:
diff --git a/src/helpers/getDefaultClassNames.test.ts b/src/helpers/getDefaultClassNames.test.ts
index 5c80fccd1..af7d43d7b 100644
--- a/src/helpers/getDefaultClassNames.test.ts
+++ b/src/helpers/getDefaultClassNames.test.ts
@@ -34,6 +34,14 @@ test("should return the default classnames", () => {
     week: "rdp-week",
     weekday: "rdp-weekday",
     weekdays: "rdp-weekdays",
-    years_dropdown: "rdp-years_dropdown"
+    years_dropdown: "rdp-years_dropdown",
+    caption_next_enter: "rdp-caption_next_enter",
+    caption_prev_enter: "rdp-caption_prev_enter",
+    weeks_next_enter: "rdp-weeks_next_enter",
+    weeks_prev_enter: "rdp-weeks_prev_enter",
+    caption_next_exit: "rdp-caption_next_exit",
+    caption_prev_exit: "rdp-caption_prev_exit",
+    weeks_next_exit: "rdp-weeks_next_exit",
+    weeks_prev_exit: "rdp-weeks_prev_exit"
   });
 });
diff --git a/src/useAnimation.test.tsx b/src/useAnimation.test.tsx
new file mode 100644
index 000000000..7f90928a4
--- /dev/null
+++ b/src/useAnimation.test.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import { user } from "@/test/user";
+
+import { nextButton, previousButton } from "../test/elements";
+
+import { DayPicker } from "./DayPicker";
+
+jest.setSystemTime(new Date(2025, 1, 10));
+
+const getMonthContainers = () => [
+  ...document.querySelectorAll(`[data-animated-month]`)
+];
+const getMonthCaptionContainers = () => [
+  ...document.querySelectorAll(`[data-animated-caption]`)
+];
+const getMonthWeekdaysContainers = () => [
+  ...document.querySelectorAll(`[data-animated-weekdays]`)
+];
+const getMonthWeeksContainers = () => [
+  ...document.querySelectorAll(`[data-animated-weeks]`)
+];
+
+describe("useAnimation", () => {
+  describe("animate prop is falsy", () => {
+    it("should not render elements with data-animated-* attributes", () => {
+      render(<DayPicker />);
+
+      expect(getMonthContainers()).toHaveLength(0);
+      expect(getMonthCaptionContainers()).toHaveLength(0);
+      expect(getMonthWeekdaysContainers()).toHaveLength(0);
+      expect(getMonthWeeksContainers()).toHaveLength(0);
+    });
+  });
+
+  describe("animate prop is true", () => {
+    it("should render elements with data-animated-* attributes", () => {
+      render(<DayPicker animate={true} numberOfMonths={2} />);
+
+      expect(getMonthContainers()).toHaveLength(2);
+      expect(getMonthCaptionContainers()).toHaveLength(2);
+      expect(getMonthWeekdaysContainers()).toHaveLength(2);
+      expect(getMonthWeeksContainers()).toHaveLength(2);
+    });
+
+    it("should add dom snapshots for each month for animation", async () => {
+      render(<DayPicker animate={true} numberOfMonths={2} />);
+
+      await user.click(nextButton());
+
+      expect(getMonthContainers()).toHaveLength(4);
+      expect(getMonthCaptionContainers()).toHaveLength(4);
+      expect(getMonthWeekdaysContainers()).toHaveLength(4);
+      expect(getMonthWeeksContainers()).toHaveLength(4);
+    });
+
+    it("should continue animating the same exiting month if month changed during animation", async () => {
+      render(<DayPicker animate={true} />);
+
+      await user.click(nextButton());
+
+      expect(getMonthCaptionContainers()[0]).toHaveTextContent("February 2025");
+      expect(getMonthCaptionContainers()[1]).toHaveTextContent("March 2025");
+
+      await user.click(nextButton());
+
+      expect(getMonthCaptionContainers()[0]).toHaveTextContent("February 2025");
+      expect(getMonthCaptionContainers()[1]).toHaveTextContent("April 2025");
+    });
+
+    it("should apply the correct animation class when entering month is after the exiting month", async () => {
+      render(<DayPicker animate={true} />);
+
+      await user.click(nextButton());
+
+      expect(getMonthCaptionContainers()[0]).toHaveClass(
+        "rdp-caption_prev_exit"
+      );
+      expect(getMonthCaptionContainers()[1]).toHaveClass(
+        "rdp-caption_next_enter"
+      );
+
+      expect(getMonthWeeksContainers()[0]).toHaveClass("rdp-weeks_prev_exit");
+      expect(getMonthWeeksContainers()[1]).toHaveClass("rdp-weeks_next_enter");
+    });
+
+    it("should apply the correct animation class when entering month is before the exiting month", async () => {
+      render(<DayPicker animate={true} />);
+
+      await user.click(previousButton());
+
+      expect(getMonthCaptionContainers()[0]).toHaveClass(
+        "rdp-caption_next_exit"
+      );
+      expect(getMonthCaptionContainers()[1]).toHaveClass(
+        "rdp-caption_prev_enter"
+      );
+
+      expect(getMonthWeeksContainers()[0]).toHaveClass("rdp-weeks_next_exit");
+      expect(getMonthWeeksContainers()[1]).toHaveClass("rdp-weeks_prev_enter");
+    });
+
+    it("should clean up the exiting month after animation ends", async () => {
+      render(<DayPicker animate={true} />);
+
+      await user.click(nextButton());
+
+      expect(getMonthContainers()).toHaveLength(2);
+      expect(getMonthCaptionContainers()).toHaveLength(2);
+      expect(getMonthWeekdaysContainers()).toHaveLength(2);
+      expect(getMonthWeeksContainers()).toHaveLength(2);
+
+      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(
+        "rdp-caption_next_enter"
+      );
+
+      expect(getMonthWeeksContainers()[0]).not.toHaveClass(
+        "rdp-weeks_next_enter"
+      );
+    });
+  });
+});
diff --git a/src/useDayPicker.test.tsx b/src/useDayPicker.test.tsx
index 2a7a07766..9d70e6ba9 100644
--- a/src/useDayPicker.test.tsx
+++ b/src/useDayPicker.test.tsx
@@ -2,7 +2,7 @@ import React from "react";
 
 import { renderHook } from "@testing-library/react";
 
-import { DayFlag, SelectionState, UI } from "./UI";
+import { Animation, DayFlag, SelectionState, UI } from "./UI";
 import { CalendarDay } from "./classes/CalendarDay";
 import { CalendarMonth } from "./classes/CalendarMonth";
 import { DayPickerProps } from "./types/props";
@@ -87,7 +87,15 @@ describe("useDayPicker", () => {
       [DayFlag.hidden]: "",
       [DayFlag.outside]: "",
       [DayFlag.focused]: "",
-      [DayFlag.today]: ""
+      [DayFlag.today]: "",
+      [Animation.weeks_next_enter]: "",
+      [Animation.weeks_prev_exit]: "",
+      [Animation.weeks_prev_enter]: "",
+      [Animation.weeks_next_exit]: "",
+      [Animation.caption_next_enter]: "",
+      [Animation.caption_prev_exit]: "",
+      [Animation.caption_prev_enter]: "",
+      [Animation.caption_next_exit]: ""
     },
     styles: {},
     labels: {