Bending the Rules of CSS View Transitions - Animating a Mega Menu

In this article we showcase making on-page animations using View Transitions, with a focus on recreating Stripe's Mega Menu using surprisingly little code.

Code on Github
πŸ§‘β€πŸ’» πŸŽ₯
... Almost ready!

View transitions is a relatively new CSS API that is mainly showcased when making nice animations going from one page to another. But in the end view transitions are based on animating updates that happen in the DOM – which could very well be small animations on a page. In this article we explore excactly that, by recreating Stripe's mega menu using surprisingly little code.

The basics of view transitions

The view transitions API is based around the document.startViewTransition() function. Any update you do within that function, is automatically animated.

document.startViewTransition(() => {
// Update the DOM in here
updateTheDomInHere();
});

This DOM update could be the rendering of a whole new page, or a small element that changes on the page. Such a small element could for example be a dropdown menu.

Toggling a dropdown

Let's start by creating a simple dropdown menu, that we can toggle open and closed by clicking a button or focussing that button.

"use client";

import { useState } from "react";
import { nav } from "./links";

export const Preview = () => {
const [activeSub, setActiveSub] = useState<null | number>(null);

return (
<>
<nav
onPointerLeave={() => setActiveSub(null)}
>
<ul className="group flex">
{nav.map((item) => (
<li
key={item.id}
className="relative"
onPointerEnter={() => setActiveSub(item.id)}
>
{!item.subnavigation && (
<a href={item.title} className="peer block px-2 py-2">
{item.title}
</a>
)}
{item.subnavigation && (
<>
<button
className="peer block px-2 py-2"
onFocus={() => setActiveSub(item.id)}
onClick={() => setActiveSub(item.id)}
aria-expanded={activeSub === item.id}
aria-controls={`subnav-${item.id}`}
>
{item.title}
</button>
<div
id={`subnav-${item.id}`}
className="absolute left-0 top-full hidden w-[500px] rounded-lg bg-white p-1 text-black peer-aria-expanded:block"
>
<div className="absolute -top-2 left-8 h-0 w-0 border-b-[12px] border-l-[12px] border-r-[12px] border-b-white border-l-transparent border-r-transparent" />
<div className="flex">
{item.leftBar && (
<div className="min-h-[300px] w-[140px] rounded-sm bg-gray-100 px-4 py-5">
<p className="text-sm">{item.leftBar}</p>
</div>
)}
<div className="w-full">
<ul className="grid grid-cols-2 gap-2 p-4">
{item.subnavigation.map((subitem) => (
<li key={subitem.title}>
<a href={subitem.href}>{subitem.title}</a>
</li>
))}
</ul>
{item.bottomBar && (
<div className="mt-4 rounded-md bg-gray-100 p-4">
<p className="text-sm uppercase">
{item.bottomBar.title}
</p>
<ul className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1">
{item.bottomBar.links.map((link) => (
<li key={link.title}>
<a href={link.href}>{link.title}</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</>
)}
</li>
))}
</ul>
</nav>
</>
);
};

This dropdown menu uses a button with an onfocus and an onclick handler to toggle it's submenu:

<button
onFocus={() => setActiveSub(item.id)}
onClick={() => setActiveSub(item.id)}
></button>

On top of that you also find another pointerLeave and pointerEnter event in the example, setting the state of the sub navigation.

Adding an animation

When the user toggles a different dropdown, we right now trigger a setState that ensures the correct dropdown is opened. If we want to animate this with view transitions, we should add the following.

import { flushSync } from "react-dom";

const [activeSub, _setActiveSub] = useState<null | number>(null);

const setActiveSub = (id: number | null) => {
document.startViewTransition(() => {
flushSync(() => {
_setActiveSub(id);
});
});
}

Note that we renamed the setActiveSub function to _setActiveSub and created a new setActiveSub function that wraps the original one. This way instead of directly updating the state, we do this via the document.startViewTransition function.

On top of that we also wrap this in the flushSync function by React, which ensures the state is updated synchronously. If you would then run that code, you get the result below.

The first thing you notice, is a flickering in the dropdown menu. Disable the onPointerLeave with the button, to see the navigation working without it. (Move your cursor over 'pricing' to close the submenu now πŸ˜…)

Why the flickering?

We've now encountered the first challenge with view transitions, showcasing why we're slightly bending the rules. The current way view transitions are built, is in such a way that whenever the animation is happening, the document freezes its current state. This means you have no way of interacting with the page during that moment, and thus hover, focus and any other events stop working. Causing our onPointerLeave to fire, and the menu to close.

Let's first appreciate the animation

But before we continue solving the flicker, let's first appreciate the animation we already get with these few lines added! By default, if you don't specify anything else, the browser fades the two states into each other. That's the animation you see now.

You can however configure that animation in any way you want. The most easy step is by telling the browser to animate individual elements, instead of the whole page. You do that by giving specific elements a CSS property view-transition-name.

<div
id={`subnav-${item.id}`}
className="[view-transition-name:subnav] absolute left-0 top-full hidden w-[500px] rounded-lg bg-white p-1 text-black peer-aria-expanded:block"
></div>

This Tailwind class adds the equivalent of view-transition-name: subnav;. And doing so, results in the following amazing animation:

To me it feels crazy, that by only adding a single CSS property we can go from a basic fade animation to such a nice animation!

Let's solve the flickering next

Like I explained before, the flickering is caused by the fact that the document freezes during the animation, and we thus trigger the pointerLeave event.

As long as there is no interruptability in view transitions yet, we need to come up with our own solution to get this to work. The solution I came up with was storing a boolean in state when an animation is happening, and if a pointerLeave happens during that time, we ignore it. Like so.

1const [activeSub, _setActiveSub] = useState<null | number>(null);
2const isTranstioningRef = useRef(false);
3
4const setActiveSub = useCallback(
5 async (id: number | null) => {
6 if (isTranstioningRef.current || id === activeSub) return;
7
8 const transition = document.startViewTransition(() => {
9 flushSync(() => {
10 isTranstioningRef.current = true;
11 _setActiveSub(id);
12 });
13 });
14
15 await transition.finished;
16
17 isTranstioningRef.current = false;
18 },
19 [activeSub],
20);

There are a few things happening here at once. Make sure to watch the full video to get a better explanation on each step.

  1. On line 2 we create a isTranstioningRef that we use to store if an animation is happening.
  2. We do this in a ref, so our setActiveSub won't create a new instance of itself, when running
  3. On line 6 we check if an animation is happening, or if we are trying to open the same submenu. If so, we return early and don't run the animation again.
  4. On line 8 we start the animation, and set the isTranstioningRef to true.
  5. On line 16 we await the animation to finish, and set the isTranstioningRef to false again. The transition variable here is also new, it's being returned by the view transition function.

By combining these extra checks, we can now prevent the menu from closing when the animation is happening. A small "hack" we need to apply now, until you can interrupt view transition animations.

Customizing the animation even further

Why stop here? We can even make the subnavigation come from either the left or the right side, depending on where your cursor is. Just like Stripe's navigation. Watch the full video on adding animations with view transitions on Youtube, to see how we do that, or take a look at the code below!

"use client";

import { useCallback, useRef, useState } from "react";
import { nav } from "./links";
import { flushSync } from "react-dom";
import "./styles.css";

export const Preview = () => {
const [activeSub, _setActiveSub] = useState<null | number>(null);
const isTranstioningRef = useRef(false);
const prevSubRef = useRef<null | number>(null);

const updateNavigation = (id: number | null) => {
if (prevSubRef.current && id) {
document.documentElement.style.setProperty(
"--subnav-direction",
prevSubRef.current < id ? "1" : "-1",
);
}
_setActiveSub(id);
};

const setActiveSub = useCallback(
async (id: number | null) => {
if (isTranstioningRef.current || id === activeSub) return;

isTranstioningRef.current = true;
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
flushSync(() => {
updateNavigation(id);
});
});
await transition.finished;
} else {
updateNavigation(id);
}

isTranstioningRef.current = false;
prevSubRef.current = id;
},
[activeSub],
);

return (
<>
<div className="md:hidden">Switch to desktop to see this demo.</div>
<nav
className="hidden md:block"
onPointerLeave={() => setActiveSub(null)}
>
<ul className="group flex">
{nav.map((item) => (
<li
key={item.id}
className="relative"
onPointerEnter={() => setActiveSub(item.id)}
>
{!item.subnavigation && (
<a href={item.title} className="peer block px-2 py-2">
{item.title}
</a>
)}
{item.subnavigation && (
<>
<button
className="peer block px-2 py-2"
onFocus={() => setActiveSub(item.id)}
onClick={() => setActiveSub(item.id)}
aria-expanded={activeSub === item.id}
aria-controls={`subnav-${item.id}`}
>
{item.title}
</button>
<div
id={`subnav-${item.id}`}
className="absolute left-0 top-full hidden w-[500px] rounded-lg bg-white p-1 text-black [view-transition-name:subnav] peer-aria-expanded:block"
>
<div className="absolute -top-2 left-8 h-0 w-0 border-b-[12px] border-l-[12px] border-r-[12px] border-b-white border-l-transparent border-r-transparent" />
<div className="flex [view-transition-name:subnavcontent]">
{item.leftBar && (
<div className="min-h-[300px] w-[140px] rounded-sm bg-gray-100 px-4 py-5">
<p className="text-sm">{item.leftBar}</p>
</div>
)}
<div className="w-full">
<ul className="grid grid-cols-2 gap-2 p-4">
{item.subnavigation.map((subitem) => (
<li key={subitem.title}>
<a href={subitem.href}>{subitem.title}</a>
</li>
))}
</ul>
{item.bottomBar && (
<div className="mt-4 rounded-md bg-gray-100 p-4">
<p className="text-sm uppercase">
{item.bottomBar.title}
</p>
<ul className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1">
{item.bottomBar.links.map((link) => (
<li key={link.title}>
<a href={link.href}>{link.title}</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</>
)}
</li>
))}
</ul>
</nav>
</>
);
};
::view-transition-old(subnavcontent) {
animation:
500ms subnavcontent-leave both,
500ms fade-out both;
}

::view-transition-new(subnavcontent) {
animation:
500ms subnavcontent-enter both,
500ms fade-in both;
}

@keyframes resize {
to {
height: 100%;
width: 100%;
}
}

@keyframes fade-in {
from {
opacity: 0;
}
}

@keyframes fade-out {
to {
opacity: 0;
}
}

@keyframes subnavcontent-enter {
from {
transform: translateX(calc(var(--subnav-direction) * 40px));
}
}

@keyframes subnavcontent-leave {
to {
transform: translateX(calc(var(--subnav-direction) * -40px));
}
}