Calendar
Calendar

Watch everything for $29/month.

Join Build UI Pro

Calendar

Animate an infinite monthly calendar using direction-aware dynamic variants.

Video resources

Summary

To start, let's add the days of the current month to our calendar. We'll be using CSS grid via Tailwind.

Let's create a new 7-column grid below our calendar's header:

<div className="mt-6 grid grid-cols-7 px-8">
  <span>1</span>
  <span>2</span>
  <span>3</span>
  <span>4</span>
  <span>5</span>
  <span>6</span>
  <span>7</span>
</div>

To get the days for the current month, we can use the eachDayOfInterval function from date-fns. We'll also use startOfMonth and endOfMonth for the start and end date:

let days = eachDayOfInterval({
  start: startOfMonth(month),
  end: endOfMonth(month),
});

If we map over these days we'll see the current days rendered:

<div>
  {days.map((day) => (
    <span key={format(day, "yyyy-MM-dd")}>{format(day, "d")}</span>
  ))}
</div>

Great start!

Next, let's add a header row for the days of week:

<div className="mt-6 grid grid-cols-7 px-8">
  <span className="font-medium text-stone-500">Su</span>
  <span className="font-medium text-stone-500">Mo</span>
  <span className="font-medium text-stone-500">Tu</span>
  <span className="font-medium text-stone-500">We</span>
  <span className="font-medium text-stone-500">Th</span>
  <span className="font-medium text-stone-500">Fr</span>
  <span className="font-medium text-stone-500">Sa</span>

  {days.map((day) => (
    /* ... */
  ))}
</div>

Looking good.

Now, the first of each month currently renders on Sunday. We can update our start and end range to the beginning and end of the week so each day falls on the correct weekday using the startOfWeek and endOfWeek functions:

let days = eachDayOfInterval({
  start: startOfWeek(startOfMonth(month)),
  end: endOfWeek(endOfMonth(month)),
});

And now the days are correctly aligned! Let's dim out the days that don't fall in the current month using the isSameMonth function:

<div className="mt-6 grid grid-cols-7 px-8">
  {/*  */}

  {days.map((day) => (
    <span
      className={isSameMonth(day, month) ? "" : "text-stone-300"}
      key={format(day, "yyyy-MM-dd")}
    >
      {format(day, "d")}
    </span>
  ))}
</div>

Looks great! Time to animate it.

Adding enter and exit animations to each month

Just like our image carousel, we want our months to enter and exit from the left and right. But unlike our carousel, we have an infinite number of months. So instead of rendering all the months and animating the parent container, we're going to animate each month individually using initial, animate and exit.

Let's start by wrapping the current month in a motion.div and setting the x property for each stage of the animation:

<motion.div
  initial={{ x: "100%" }}
  animate={{ x: "0%" }}
  exit={{ x: "-100%" }}
>
  <header>{/*  */}</header>
  <div>{/*  */}</div>
</motion.div>

Looks like this only takes effect on initial render!

This is because our div is only being mounted once. Even though we're changing our monthString state, the div itself is not being unmounted and re-mounted, so our enter and exit animations never run.

We can key our div by the monthString state to force React to unmount and remount it every time the state changes:

<motion.div
  key={monthString}
  initial={{ x: "100%" }}
  animate={{ x: "0%" }}
  exit={{ x: "-100%" }}
>
  <header>{/*  */}</header>
  <div>{/*  */}</div>
</motion.div>

Sweet! Now every time we change the month, we see some animations running.

It looks like our enter animation is running, but not our exit. That's because we need an AnimatePresence above our keyed div:

<AnimatePresence>
  <motion.div
    key={monthString}
    initial={{ x: "100%" }}
    animate={{ x: "0%" }}
    exit={{ x: "-100%" }}
  >
    {/*  */}
  </motion.div>
</AnimatePresence>

Now all the stages are running.

Let's slow things down a bit so we can see what's happening.

<MotionConfig transition={{ duration: 3 }}>
  {/* ...  */}
</MotionConfig>

First, we can take care of the overflow by adding overflow-hidden to the div with bg-white.

Next, we can see that both the entering and exiting months are being rendered at the same time, and they're both taking up space in the DOM. This is because of the default mode that AnimatePresence uses – sync – like we discussed in the previous lesson.

We want the exiting month to be popped out of the layout to make room for the entering month, so we'll change the mode to popLayout:

<AnimatePresence mode="popLayout">
  <motion.div
    key={monthString}
    initial={{ x: "100%" }}
    animate={{ x: "0%" }}
    exit={{ x: "-100%" }}
  >
    {/*  */}
  </motion.div>
</AnimatePresence>

The overflow issue is back because popLayout creates its own stacking context. Adding relative to the div with overflow-hidden fixes this.

Finally, let's disable the initial mount animation with initial={false} on AnimatePresence.

Now our months are animating in and out!

Using variants to animate specific children

Currently, the entire month, including the buttons, slides in and out. This is because the buttons are nested within the keyed div, so they're part of the animation.

We don't want the buttons to move, since conceptually they're part of the interface.

Let's try moving the key and animation props down the tree to the specific elements we want to animate. We'll start with the month title:

<motion.p
  key={monthString}
  initial={{ x: "100%" }}
  animate={{ x: "0%" }}
  exit={{ x: "-100%" }}
  className="absolute inset-0 flex items-center justify-center font-semibold"
>
  {format(month, "MMMM yyyy")}
</motion.p>

Something strange is happening... enter seems to work, but not exit. This is because for exit animations to work, AnimatePresence needs to be a direct parent of the unmounting element.

We can add it above the title:

<AnimatePresence>
  <motion.p
    key={monthString}
    initial={{ x: "100%" }}
    animate={{ x: "0%" }}
    exit={{ x: "-100%" }}
    className="absolute inset-0 flex items-center justify-center font-semibold"
  >
    {format(month, "MMMM yyyy")}
  </motion.p>
</AnimatePresence>

...but this is kind of a bummer. Ideally we wouldn't have to add an AnimatePresence every time we want to run an unmount animation on a specific child.

There's a better way to handle this: using variants.

Let's undo our code and move the animation back to the root:

<AnimatePresence mode="popLayout" initial={false}>
  <motion.div
    key={monthString}
    initial={{ x: "100%" }}
    animate={{ x: "0%" }}
    exit={{ x: "-100%" }}
  >
    {/* ... */}
  </motion.div>
</AnimatePresence>

and then refactor this code to use variants:

<AnimatePresence mode="popLayout" initial={false}>
  <motion.div
    key={monthString}
    initial="enter"
    animate="middle"
    exit="exit"
    variants={{
      enter: { x: "100%" },
      middle: { x: "0%" },
      exit: { x: "-100%" },
    }}
  >
    {/* ... */}
  </motion.div>
</AnimatePresence>

This is a transparent refactor – the behavior is exactly the same as before. But variants have a neat feature: the active variant of a given motion element flows down to every child motion element in the tree.

Let's remove the variants from the root div, but leave the initial, animate and exit props defined:

<AnimatePresence mode="popLayout" initial={false}>
  <motion.div
    key={monthString}
    initial="enter"
    animate="middle"
    exit="exit"
  >
    {/* ... */}
  </motion.div>
</AnimatePresence>

Our months no longer animate. But, if we come down to the title and paste our variants there

<AnimatePresence mode="popLayout" initial={false}>
  <motion.div
    key={monthString}
    initial="enter"
    animate="middle"
    exit="exit"
  >
    {/* ... */}

    <motion.p
      variants={{
        enter: { x: "100%" },
        middle: { x: "0%" },
        exit: { x: "-100%" },
      }}
      className="absolute inset-0 flex items-center justify-center font-semibold"
    />
  </motion.div>
</AnimatePresence>

...the title now animates, on both enter and exit.

So, variants flow down, including exit variants, which lets us target specific children all while using a single AnimatePresence at the root.

This is a great solution for building animations like this one, where specific elements in the tree animate differently from one another.

Let's do the same thing for our grid of days. This will use the same variant definitions as our title, so let's extract them to a variable

let variants = {
  enter: { x: "100%" },
  middle: { x: "0%" },
  exit: { x: "-100%" },
};

and use them on both the title and grid:

<motion.p
  variants={variants}
  className="absolute inset-0 flex items-center justify-center font-semibold"
>
  {/* ... */}
</motion.p>

<motion.div
  variants={variants}
  className="mt-6 grid grid-cols-7 gap-y-6 px-8"
/>

And just like that, our title and grid are animating, but our buttons are staying fixed!

In Airbnb's calendar, they also fix the weekday titles. Let's replicate this by moving the titles to a separate div outside of the animated grid. We'll just duplicate the grid code.

<div className="mt-6 grid grid-cols-7 gap-y-6 px-8 text-sm">
  <span className="font-medium text-stone-500">Su</span>
  <span className="font-medium text-stone-500">Mo</span>
  <span className="font-medium text-stone-500">Tu</span>
  <span className="font-medium text-stone-500">We</span>
  <span className="font-medium text-stone-500">Th</span>
  <span className="font-medium text-stone-500">Fri</span>
  <span className="font-medium text-stone-500">Sa</span>
</div>

<motion.div
  variants={variants}
  className="mt-6 grid grid-cols-7 gap-y-6 px-8 text-sm"
/>

Pretty cool! The ability for variants to flow down through a tree from a single root really comes in handy.

Visually hiding duplicated elements

If we look close, we'll see a small visual bug.

The children that we aren't animating – the weekday titles and the buttons – are actually being duplicated while the unmounting month's exit animation runs. This is because they have no animation, but because we're using popLayout they appear twice on top of each other.

We can fix this by adding an animation that immediately sets the CSS property visibility to hidden:

<motion.div
  variants={{ exit: { visibility: "hidden" } }}
  className="mt-6 grid grid-cols-7 gap-y-6 px-8 text-sm"
>
  <span className="font-medium text-stone-500">Su</span>
  <span className="font-medium text-stone-500">Mo</span>
  <span className="font-medium text-stone-500">Tu</span>
  <span className="font-medium text-stone-500">We</span>
  <span className="font-medium text-stone-500">Th</span>
  <span className="font-medium text-stone-500">Fri</span>
  <span className="font-medium text-stone-500">Sa</span>
</motion.div>

This fixes the bug.

Let's do the same thing for the buttons. We'll reuse this variant definition, so let's throw it in a variable:

let removeImmediately = {
  exit: { visibility: "hidden" },
};

and use this for our weekday titles and buttons:

<motion.button
  variants={removeImmediately} />

<motion.div
  variants={removeImmediately}
  className="mt-6 grid grid-cols-7 gap-y-6 px-8 text-sm"
/>

Now the duplicated elements are hidden from the screen.

Direction-aware animation with dynamic variants

It's time to make our animation dynamic based on the direction the user is scrolling the calendar.

Currently, our variants are hard-coded to always animate months to the left:

// Animate months left
let variants = {
  enter: { x: "100%" },
  middle: { x: "0%" },
  exit: { x: "-100%" },
};

We need to flip these numbers (so enter is negative and exit is positive) if we want to animate to the right:

// Animate months right
let variants = {
  enter: { x: "-100%" }, // invert from above
  middle: { x: "0%" },
  exit: { x: "100%" }, // invert from above
};

So, we need some way to account for the direction in our variant math.

Thinking back to our image carousel, we actually got this behavior for free. This is because all the images were rendered the entire time, and we were just calculating the parent container's final x position. So, if we went from image 1 to 4, the container moved right, but if we went from 10 to 8, the container moved left. We didn't have to do any extra work to account for the direction.

This is why if you're working on a carousel-like animation where the number of elements is fixed, it's nice to use the parent containe approach. You get a lot of behavior for free.

However, our calendar has an infinite number of months, so each month has to animate on its own. This means that when the animation kicks off, we need to know whether the months should move right or left.

Right now, the only piece of information we have for a given render is the currently selected month (e.g. 2022-12) which isn't enough information to know whether we're coming from the previous month or the next month.

So, we also need access to the direction that was clicked, so that we can use that direction in our variant math.

There's a few ways we could get the direction, but for this example, the easiest way is to add some new React state:

export default function Page() {
  let [monthString, setMonthString] = useState(format(new Date(), "yyyy-MM"));
  let [direction, setDirection] = useState();

  // ...
}

In our click handlers, let's set the direction right after we update monthString. We'll use 1 to represent next and -1 for previous:

function nextMonth() {
  let next = addMonths(month, 1);

  setMonthString(format(next, "yyyy-MM"));
  setDirection(1);
}

function previousMonth() {
  let previous = subMonths(month, 1);

  setMonthString(format(previous, "yyyy-MM"));
  setDirection(-1);
}

Now we have direction available to us during render. But how can access it in our variant math?

Currently, our variants are defined outside of render:

export default function Page() {
  let [monthString, setMonthString] = useState(
    format(new Date(), "yyyy-MM")
  );
  // Direction is only available during render...
  let [direction, setDirection] = useState();

  return <MotionConfig>{/* ... */}</MotionConfig>;
}

// ...but our variants are defined outside of render.
let variants = {
  enter: { x: "100%" },
  middle: { x: "0%" },
  exit: { x: "-100%" },
};

So, they don't have access to state that's within our component.

We could inline our variants back to within our render function, but there's a better way: the custom prop.

<motion.p variants={variants} custom={""} />

The custom prop, which is available on all motion elements, has the following definition:

custom: Custom data used to resolve dynamic variants differently for each animating component.

Sounds like just what we need! This prop lets us pass render-time data into our motion elements, which we can then access within our variants by turning those variants into functions.

Let's pass in our new direction state:

<motion.p variants={variants} custom={direction} />

Now, we can turn our variants into functions that take in direction as a parameter. Let's start with enter:

  let variants = {
-   enter: { x: "100%" },
+   enter: (direction) => {
+     console.log("enter: ", direction);
+
+     return { x: "100%" };
+   },
    middle: { x: "0%" },
    exit: { x: "-100%" },
  };

If we cycle our months, we see the direction in the console! Moving right logs 1 and moving left logs -1.

We also see an undefined in the log. This is because we have two elements using these variants: the title and the grid. Let's add custom to the grid as well.

// Title
<motion.p variants={variants} custom={direction} />

// Grid
<motion.div variants={variants} custom={direction} />

Ok – now that we have access to direction, let's multiply it by 100 to get our final x value:

enter: (direction) => {
  console.log("enter: ", direction);

  return { x: `${100 * direction}%` };
};

Something's happening but there's a lot going on! Let's comment out our exit variant entirely and just focus on enter. And it looks good! New months enter from the right if we cycle forward in months, and they enter from the left if we cycle backwards.

So we can see that dynamic variants are the perfect solution to this problem. They let us use render-time data like React state directly inside of our variant definitions.

Let's do the same thing with our exit variant, using -100 instead of 100 in our math:

let variants = {
  enter: (direction) => {
    console.log("enter: ", direction);

    return { x: `${100 * direction}%` };
  },

  middle: { x: "0%" },

  enter: (direction) => {
    console.log({ direction });

    return { x: `${-100 * direction}%` };
  },
};

If we cycle a month forward, there's a lot going on! Let's slow the whole thing down.

The first animation is weird... the entering month looks correct but the exiting month disappears instantly. But if we keep advancing months, it looks like things start to work.

However, as soon as we change direction and start cycling backwards, we see another bug. The entering month is still correct, but the exiting one moves in the wrong direction. If we cycle backwards again, the exiting month starts working.

What's going on?

Looking at the logs gives us a hint. The value of direction seems to be correct for enter but looks stale for exit. It's always one render behind.

Let's add a label next to the month that renders the direction state to help us debug this.

<motion.p
  variants={variants}
  custom={direction}
  className="absolute inset-0 flex items-center justify-center font-semibold"
>
  {format(month, "MMMM yyyy")} (direction: {direction})
</motion.p>

On initial render, the direction is undefined, since we gave it no default value. Once we click the right button, we can see the entering month animating in from the right, and it shows the correct direction of 1. But if we click left, we'll see something interesting. The exiting month still shows the old direction of 1, while the entering month shows the correct new direction of -1.

So, the entering month is getting the latest version of direction, while the exiting month seems like it's using a stale value.

The reason this is happening is because of how React works. When an element is unmounted in React, it is immediately removed from the tree. And this is why unmount animations and transitions are not possible in vanilla React. Framer Motion addresses this issue with AnimatePresence, and the way it works is by making a static copy of the unmounting tree and appending it to the DOM, which gives us a chance to animate it out before it's removed entirely.

So, exit animations in Framer Motion are essentially a static copy of a React component tree that has been unmounted. And once a tree is unmounted, React has no opportunity to update any of its state or props. And that explains why exiting elements will always reference stale data. They're one render behind the current tree as they animate out.

Fortunately, Framer Motion has our back here.

If we take a look at the props available on AnimatePresence, we'll see that it also has a custom prop:

<AnimatePresence custom={}>
  {/* ... */}
</AnimatePresence>

Here's the definition:

When a component is removed, there's no longer a chance to update its props. So if a component's exit prop is defined as a dynamic variant and you want to pass a new custom prop, you can do so via AnimatePresence. This will ensure all leaving components animate using the latest data.

Sounds like exactly what we need! We're using dynamic variants (variants as functions), and we want those functions to use the latest version of the direction we're passing into custom.

So, let's pass in direction as custom data to our AnimatePresence:

<AnimatePresence custom={direction}>
  {/* ... */}
</AnimatePresence>

And just like that, everything works! Both our enter and exit variants now operate on the same value of direction, and our months move either to the left or right depending on which button is pressed.

Magic!

Interruptibility

Our calendar looks great so far, but there's an issue. If we slow down the transition and try clicking the right button quickly, we'll see our animation gets quite buggy. Our code isn't resilient to interruptibility.

To make our calendar fully interruptible is beyond the scope of this lesson. As an aside, recall that we got interruptibility for free with our image carousel. This is another reason you should use that technique if you're dealing with a fixed number of animating elements.

For now, we can handle this issue the same way Airbnb's calendar does: by preventing the ability to cycle months while an animation is in progress.

We can do this using some new React state:

export default function Page() {
  let [monthString, setMonthString] = useState(format(new Date(), "yyyy-MM"));
  let [direction, setDirection] = useState();
  let [isAnimating, setIsAnimating] = useState(false);

  return <MotionConfig>{/* ... */}</MotionConfig>;
}

Let's set isAnimating to true directly in our event handlers:

function nextMonth() {
  let next = addMonths(month, 1);

  setMonthString(format(next, "yyyy-MM"));
  setDirection(1);
  setIsAnimating(true);
}

function previousMonth() {
  let previous = subMonths(month, 1);

  setMonthString(format(previous, "yyyy-MM"));
  setDirection(-1);
  setIsAnimating(true);
}

and we can use the onExitComplete prop from AnimatePresence to reset it back to false once the animation completes:

<AnimatePresence
  mode="popLayout"
  initial={false}
  custom={direction}
  onExitComplete={() => setIsAnimating(false)}
/>

Now, we can use our new state to return early from our event handlers if isAnimating is true:

function nextMonth() {
  if (isAnimating) return;

  let next = addMonths(month, 1);

  setMonthString(format(next, "yyyy-MM"));
  setDirection(1);
  setIsAnimating(true);
}

function previousMonth() {
  if (isAnimating) return;

  let previous = subMonths(month, 1);

  setMonthString(format(previous, "yyyy-MM"));
  setDirection(-1);
  setIsAnimating(true);
}

This is a good compromise that prevents a lot of bugs, and doesn't degrade the UX too much when our transition is running at full speed.

Fading the month titles with a gradient

If we slow down the animation, we'll see our month titles collide with our buttons as they animate.

A simple way to fix this is to use an absolutley positioned div with a transparent gradient to hide the titles when they are near the buttons.

<header className="relative flex justify-between px-8">
  <motion.p
    variants={variants}
    custom={direction}
    className="absolute inset-0 flex items-center justify-center font-semibold"
  >
    {format(month, "MMMM yyyy")}
  </motion.p>

  <div
    className="absolute inset-0"
    style={{
      backgroundImage:
        "linear-gradient(to right, white 15%, transparent 30%, transparent 70%, white 85%)",
    }}
  />
</header>

Resizing the calendar's height

Finally, you may have noticed that our panel sometimes grows and shrinks, since some months have 5 rows of weeks and some have 6.

This is a perfect opportunity to bring in our resizable panel from the previous lesson.

We can first extract the code from that lesson into a standalone component:

function ResizablePanel({ children }) {
  let [ref, bounds] = useMeasure();

  return (
    <motion.div
      animate={{ height: bounds.height > 0 ? bounds.height : null }}
    >
      <div ref={ref}>{children}</div>
    </motion.div>
  );
}

Now, if we wrap our calendar's AnimatePresence in our ResizablePanel

<ResizablePanel>
  <AnimatePresence mode="popLayout" initial={false} custom={direction}>
    <motion.div
      key={monthString}
      initial="enter"
      animate="middle"
      exit="exit"
    >
      {/* ... */}
    </motion.div>
  </AnimatePresence>
</ResizablePanel>

...the calendar smoothly animates its height when we move between months with 5 or 6 weeks!

This is an awesome testament to how well-designed these libraries are, and the power of composition in React.

Join Build UI Pro to access this video's summary and source code.

Join Build UI Pro

Watch every video, support our work, and get exclusive perks!

Build UI is the new home for all our ideas. It will eventually have hundreds of premium videos and a thriving community, but right now it's the early days.

If you like what you see and you've ever wanted to support our work, subscribe today and start enjoying all the perks of becoming a member!

$29/month

Watch everything. Cancel anytime.

What you'll get as a Build UI Pro member

Full access to all Build UI videos

Get full access to all of our premium video content, updated monthly.

Private Discord

Ask questions and get answers from Sam, Ryan and other pro members.

Video summaries with code snippets

Easily reference videos with text summaries and copyable code snippets.

Source code

View the source code for every video, right on GitHub.

Invoices and receipts

Get reimbursed from your employer for becoming a better coder!