Make an Animated Menu like Stripe with React, Tailwind, and AI
Published: 2023-11-03 15:41:17

_Written by [Steve Sewell](https://twitter.com/steve8708)._

---

How does Stripe make this awesome morphing menu animation?

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fe711016120734ac2a2cdfdc76be08bb9%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=e711016120734ac2a2cdfdc76be08bb9&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source></video>

Let’s recreate this in React with just a few lines of logic:

```ts
const [hovering, setHovering] = useState<number | null>(null);
const [popoverLeft, setPopoverLeft] = useState<number | null>(null);
const [popoverHeight, setPopoverHeight] = useState<number | null>(null);

const refs = useRef<(HTMLElement | null)[]>([]);

const onMouseEnter = (index: number, el: HTMLElement) => {
  setHovering(index);
  setPopoverLeft(el.offsetLeft);
  const menuElement = refs.current[index];
  if (menuElement) {
    setPopoverHeight(menuElement.offsetHeight);
  }
};
```

We’ll also use AI and Tailwind to create the markup, to quickly go from a basic hello world app to this as our end result:

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F36d13a4f0bc641cfb25862cda53fbe3b%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=36d13a4f0bc641cfb25862cda53fbe3b&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

## Generate the markup

Let's start with a blank React app. You can use Next.js, Remix, or even be a cool kid and use [Qwik](https://qwik.builder.io/) if you like. Here is where I started:

```ts
export default function Home() {
  return <h1>Hello world</h1>;
}
```

 ![Screenshot of a "hello world" in a browser window](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fd689375edea14ff18fd1c3f418b42555?width=800)

Stunning!

But this is pretty far from looking like what we want, and hand coding an entire Stripe site will take a lot of time.

But thankfully, we have AI to get us 80% of the way there without all that work.

I started with these [mockups in Figma](https://www.figma.com/file/kuS2bsCgOt8DdhN1pNCMaG/Stripe-Site?type=design&node-id=0%3A1&mode=design&t=kgNqMgEpfprcYkHd-1), and used the [Builder.io plugin](https://www.figma.com/community/plugin/747985167520967365/builder-io-ai-powered-figma-to-code-react-vue-tailwind-more) to convert them to React + Tailwind code using [Visual Copilot](https://www.builder.io/blog/figma-to-code-visual-copilot).

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F9eaf8cfed4a343d8aa6447cec7ee7338%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=9eaf8cfed4a343d8aa6447cec7ee7338&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

By just clicking **Generate** , we get launched into [Builder.io](http://Builder.io), and we can copy the code and paste it into our codebase.

I put it into a new component that I named `StripeHero`:

```ts
export function StripeHero() {
  return /* markup generated by Builder.io */
}
```

I then import that into my page:

```ts
import { StripeHero } from "@/components/StripeHero";

export default function Home() {
  return <StripeHero />;
}
```

And we get this:

 ![Screenshot of our new page that looks almost identical to Stripes homepage](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F75b462562b394fbfb83ac26650a403f7?width=800)

Much better!

Now we want to extract the markup for the navigation links into their own component so we can add our logic to them.

From our `StripeHero` component, I cut this section out and brought it to a new `Nav` component:

```ts
export function Nav() {
  return (
    <nav className="items-start self-center flex w-[486px] max-w-full justify-between gap-5 my-auto max-md:flex-wrap max-md:justify-center">
      <a
        href="/products"
        className="text-white text-center text-base font-medium leading-6 tracking-wide self-stretch"
      >
        Products
      </a>
      <a
        href="/solutions"
        className="text-white text-center text-base font-medium leading-6 tracking-wide self-stretch"
      >
        Solutions
      </a>
      ... the other links
    </nav>
  );
}
```

And then back in our `StripeHero`, we will reference this component instead:

```ts
import { Nav } from './Nav'

export function StripeHero() {
  return (
    <>
      ...
      <Nav />
      ...
    </>
  )
}
```

Now that we didn’t have to waste time generating all of that markup and styling, let's plug in our logic and make those nice interactions.

## Adding the logic

Going back at our animation we want to copy again:

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fe711016120734ac2a2cdfdc76be08bb9%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=e711016120734ac2a2cdfdc76be08bb9&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

There are only three things we need to track:

1. We need to know which link we’re hovering over. We’ll store that as a number.
2. We need to know the left offset that the menu should have, so that it positions itself under the relevant link.
3. We need to know the height of the current nav section to show, so we can resize the popover height to match it.

So back in our nav component, let’s add these.

```ts
export function Nav() {
  const [hovering, setHovering] = useState<number | null>(null);
  const [popoverLeft, setPopoverLeft] = useState<number | null>(null);
  const [popoverHeight, setPopoverHeight] = useState<number | null>(null);

  return /* the markup generated by Builder.io */
}
```
 ![](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2F86cb85533e5d469e8ffeed519e2a794a?width=45)

> **Note:** There are better ways for managing state than repeated useState hooks like this, such as using [reducers, libraries, or custom hooks](https://www.builder.io/blog/use-reducer), but to keep things simple for learning purposes, we’ll stick to `useState` today.

Now, let's start plugging these in.

On mouse enter of each link, we will set the hovering to the correct index, and when we leave the nav entirely, we will set hovering back to `null`.

Additionally, when we are hovering over a link, we’ll have a popover show.

```ts
export function Nav() {
  // ...
  return (
    <nav onMouseEnter={() => setHovering(null)}>
      <a onMouseEnter={() => setHovering(0)} ...>
        Products
      </a>
      <a onMouseEnter={() => setHovering(1)} ...>
        Solutions
      </a>
      ...
      {typeof hovering === 'number' && (
        <div className="absolute shadow bg-white p-5 rounded w-[600px] ...">
          {/* Our popover */}
        </div>
      )}
    </nav>
  )
}
```

Now we’ve got a basic popover when we hover links.

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F66923f4083ae441eb2180cd4fd29940b%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=66923f4083ae441eb2180cd4fd29940b&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Now, let's make it so that when I hover different links, the popover follows us to which link our mouse is over.

To do that, we’ll need to set the `popoverLeft` value. We can add that to our `onMouseEnter` callbacks by setting `popoverLeft` to the `event.currentTarget.clientLeft`. We can do it for each link like so:

```ts
export function Nav() {
  // ...
  const [popoverLeft, setPopoverLeft] = useState<number | null>(null);

  return (
    // ...
      <a 
        onMouseEnter={(event) => {
          setHovering(0);
          setPopoverLeft(event.currentTarget.clientLeft);
        }}
        ...
        >
        Products
      </a>
      <a 
        onMouseEnter={(event) => {
          setHovering(1);
          setPopoverLeft(event.currentTarget.clientLeft);
        }}
        ...
        >
        Solutions
      </a>
    // ...
  )
}
```

Stay with me — we’ll refactor this logic later to be less redundant.

Then, we just need to set the left value to the popover itself. We’ll also add a `transition-all` class so that our popover animates when it moves.

```ts
export function Nav() {
  // ...
      {typeof hovering === 'number' && (
        <div 
          style={{
            left: popoverLeft ?? 0
          }}
          className="transition-all ...">
          {/* ... */}
        </div>
      )}
    // ...
}
```

Now it gives us a popover that follows you as expected:

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F7779caac851f47d89810687364915ffd%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=7779caac851f47d89810687364915ffd&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Now, the next thing we need is something to live inside of our popover.

I’m going to go back to our [Figma design](https://www.figma.com/file/kuS2bsCgOt8DdhN1pNCMaG/Stripe-Site?type=design&node-id=0%3A1&mode=design&t=kgNqMgEpfprcYkHd-1) and use the [Builder.io figma plugin](https://www.figma.com/community/plugin/747985167520967365/builder-io-ai-powered-figma-to-code-react-vue-tailwind-more) to convert each of the popover menus to React + Tailwind code.

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F4409d04714ab46e99eae2e580ce90336%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=4409d04714ab46e99eae2e580ce90336&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Now in VS Code I made components for each Menu and pasted the code in there.

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F3a0db2241f3b40cc823674311fea1aa4%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=3a0db2241f3b40cc823674311fea1aa4&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

One thing we’ll need to do shortly is to measure the client height of each of these menus. In order to do that, we’ll need access to the underlying DOM node.

In order to provide access to the inner DOM node to a parent component, we’ll wrap each of these components in react’s `forwardRef` to make this easy.

```ts
import { forwardRef } from 'react';

export const Menu3 = forwardRef<HTMLElement>(props, ref) => {
  return (
    <section ref={ref} ...>
      {/* Markup generated by Builder.io */}
    </section>
  )
})
```

Now, back in our `Nav` component we can plug the menus into our popover based on which is being hovered like so:

```ts
export function Nav() {
  // ...
      {typeof hovering === 'number' && (
        <div ...>
          {hovering === 0 ? (
            <Menu0 />
          ) : hovering === 1 ? (
            <Menu1 />
          ) : hovering === 2 ? (
            <Menu2 />
          ) : hovering === 3 ? (
            <Menu3 />
          ) : null}
        </div>
      )}
    // ...
}
```

The trick here is we actually need to render all of the menus at once, so we can fade each in and out individually without them popping in and out of the DOM.

So now our `Nav` component will look like this instead:

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F63e1b66f13244467a2ea9748348a9b24%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=63e1b66f13244467a2ea9748348a9b24&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Now, this isn’t the prettiest way to show a different view by index, but we will refactor this later.

Also, speaking of keeping your code clean, there is one other ugly thing we do here that we should generally do better.

In reality, you’d generally want to give each of those menus a more descriptive name, like `ProductsMenu` and `SolutionsMenu`, but I got lazy and forgot to rename this before building out this example so bear with me and name your own components better. 😄

Now that things are functional, we just need to add the nice animations.

```ts
export function Nav() {
  // ...
      {typeof hovering === 'number' && (
        <div ...>
          <Menu0 />
          <Menu1 />
          <Menu2 />
          <Menu3 />
        </div>
      )}
    // ...
}
```

But of course, we need to overlay each menu so we can fade one into the next. To do so, we’ll make each absolute:

```ts
export function Nav() {
  // ...
      {typeof hovering === 'number' && (
        <div ...>
          <div className="absolute">
            <Menu0 />
          </div>
          <div className="absolute">
            <Menu1 />
          </div>
          ...
        </div>
      )}
    // ...
}
```

Now that they all overlap, we just need to animate the active one.

I’m going to use a handy called `clsx` that will make it a lot easier to add dynamic classes in React (and the library < 300 bytes).

We’ll use `clsx` to make the non-active menus `opacity-0` and `pointer-events-none` to make sure they aren’t clickable. We will also transition the opacity (and soon transform) via `transition-all`.

```ts
export function Nav() {
  // ...

      {typeof hovering === "number" && (
        <div ...>
          <div className={clsx(
            "absolute transition-all",
            hovering === 0 ? "opacity-100" : "opacity-0 pointer-events-none"
          )}>
            <Menu0 />
          </div>
          <div className={clsx(
            "absolute transition-all",
            hovering === 1 ? "opacity-100" : "opacity-0 pointer-events-none"
          )}>
            <Menu1 />
          </div>
        </div>
      )}
    // ...
}
```

Now in our React app, we’ve got our transition, but something is clearly wrong here:

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F49e2691cf26c4bf098a3a201b7f91cac%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=49e2691cf26c4bf098a3a201b7f91cac&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Oh ya! We need to hook up that `popoverHeight` state we defined earlier. Otherwise, given our inner contents are all position absolute, they break out of the typical document flow and don’t push the popover height to be their height automatically (like they would without position: absolute being set).

So now, back in our nav component, we need to set the popover height.

This is a good time to recognize that our mouseenter listeners are quite redundant, so let's start by refactoring those.

Let’s create a new `onMouseEnter` function that encapsulates our current logic, like so:

```ts
export function Nav() {
  // ...
  const onMouseEnter = (index: number, el: HTMLElement) => {
    setHovering(index);
    setPopoverLeft(el.offsetLeft);
    // We will add the popover height logic here shortly:
    // setPopoverHeight(...)
  };

  // ...
  <a onMouseEnter={(event) => onMouseEnter(0, event.currentTarget)} ...>
    Products
  </a>
  <a onMouseEnter={(event) => onMouseEnter(1, event.currentTarget)} ...>
    Solutions
  </a>
  // etc...
}
```

Here, we can take the index and anchor element as arguments, and better encapsulate our logic.

Now, we can add our `popoverHeight` logic directly in here.

But first, we need a reference to the element wrapping the inner contents for each of our menus.

We will use the `useRef` hook, but because we have a list of menu items, we’ll keep a list of references like so:

```ts
export function Nav() {
  // ...
  const refs = useRef<(HTMLElement | null)[]>([]);
  
  // ...
  <Menu0 ref={element => refs.current[0] = element} />
  // ...
  <Menu1 ref={element => refs.current[1] = element} />
  // etc...
}
```

We can use `ref` on each of the `Menu` components because of the `forwardRef` we applied to each earlier.

Now that we have refs to all of the root elements of each menu, we can add our height logic to our new `onMouseEnter` function and set the value as the `height` style property for our popover.

```ts
export function Nav() {
  // ...
  const onMouseEnter = (index: number, el: HTMLElement) => {
    // ...
    const menuElement = refs.current[index];
    if (menuElement) {
      setPopoverHeight(menuElement.offsetHeight);
    }
  };
  // ...

  <div style={{
    left: popoverLeft ?? 0,
    height: popoverHeight ?? 0
  >
  // ...
}
```

Now things are getting pretty good! We have the popover `left` and `height` transitioning, and each menu item fading in.

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fc04e993ee5854a12b967ce926d0d5ab2%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=c04e993ee5854a12b967ce926d0d5ab2&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

Now there is one last piece, which is the best part: fading the menus left and right to make them appear to morph from one to the next.

To do this, we need to add some logic to each menu item to transform the menus to the left if it is before the active menu, center if it is active, and right if it is after the active menu.

But first, let's clean up our code a bit.

I’m going to bring the per-menu animation logic to be its own wrapper components where we will pass the menu’s index, hovering index, and children as props:

```ts
import clsx from "clsx";

export function SlideWrapper(props: {
  index: number;
  hovering: number | null;
  children: React.ReactNode;
}) {
  return (
    <div
      className={clsx(
        "absolute w-full transition-all duration-300",
        props.hovering === props.index ? "opacity-100" : "opacity-0 pointer-events-none"
      )}
    >
      {props.children}
    </div>
  );
}
```

Now I can go back and apply this to my `Nav` component, wrapping each menu, which looks much nicer:

```ts
import { SlideWrapper } from './components'

export function Nav() {
  // ...
  <SlideWrapper index={0} hovering={hovering}>
    <Menu0 ref={(ref) => (refs.current[0] = ref)} />
  </SlideWrapper>
  <SlideWrapper index={1} hovering={hovering}>
    <Menu1 ref={(ref) => (refs.current[1] = ref)} />
  </SlideWrapper>
  <SlideWrapper index={2} hovering={hovering}>
    <Menu2 ref={(ref) => (refs.current[2] = ref)} />
  </SlideWrapper>
  <SlideWrapper index={3} hovering={hovering}>
    <Menu3 ref={(ref) => (refs.current[3] = ref)} />
  </SlideWrapper>
  // ...
}
```

Now, back in our `SlideWrapper` component, we can add the logic we described, to change the transform based on if this menu is before, after, or equal to the current hovering index:

```ts
export function SlideWrapper(props: {
  index: number;
  hovering: number | null;
  children: React.ReactNode;
}) {
  return (
    <div
      className={clsx(
        "absolute w-full transition-all duration-300",
        props.hovering === props.index ? "opacity-100" : "opacity-0 pointer-events-none",
        props.hovering === props.index || props.hovering === null
          ? "transform-none"
          : props.hovering! > props.index
          ? "-translate-x-24"
          : "translate-x-24",
      )}
    >
      {props.children}
    </div>
  );
}
```

Now we’ve got this amazing end result!

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F936bf6ddbb81418aa7c35e5a2bc57893%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=936bf6ddbb81418aa7c35e5a2bc57893&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video>

## Conclusion

This technique you can now use for any type of menu, with any size of inner contents.

As a bonus, the actual Stripe menus have a nice floating arrow that points to the current link, and can even resize to hold menus of different widths. If you’d like a challenge, try adding that logic as well. It’s not too different than what we have, and adds very nice touches.

As a recap, here are the resources we used so you can build this for yourself too:

- [The Figma designs](https://www.figma.com/file/kuS2bsCgOt8DdhN1pNCMaG/Stripe-Site?type=design&node-id=0%3A1&mode=design&t=kgNqMgEpfprcYkHd-1)
- [The Builder.io Figma plugin](https://www.figma.com/community/plugin/747985167520967365/builder-io-ai-powered-figma-to-code-react-vue-tailwind-more) to [convert the designs to code](https://www.builder.io/blog/figma-to-code-visual-copilot)

<video muted autoplay loop playsinline controls>
      <source src="https://cdn.builder.io/o/assets%2FYJIGb4i01jvw0SRdL5Bt%2F0b0cf2694b0442f7a5c51628fa278e56%2Fcompressed?apiKey=YJIGb4i01jvw0SRdL5Bt&amp;token=0b0cf2694b0442f7a5c51628fa278e56&amp;alt=media&amp;optimized=true" type="video/mp4">
    </source>
</video> ![](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fa59bf5c79450490387c6ffe5914637d6?width=389) ![](https://cdn.builder.io/api/v1/image/assets%2FYJIGb4i01jvw0SRdL5Bt%2Fec73bc7266784389afd672007bfbdc94?width=308)

Introducing Visual Copilot: a new AI model to convert Figma designs to high quality code in a click.  
  
No setup needed. 100% free. Supports all popular frameworks.

[Try Visual Copilot](https://builder.io/signup)
##### _Read the [full post](https://www.builder.io/blog/stripe-menus) on the [Builder.io blog](https://www.builder.io/blog)_