DEV Community

Cover image for Make an Animated Menu like Stripe with React, Tailwind, and AI
Steve Sewell for Builder.io

Posted on • Originally published at builder.io on

Make an Animated Menu like Stripe with React, Tailwind, and AI

Written by Steve Sewell.


How does Stripe make this awesome morphing menu animation?

Let’s recreate this in React with just a few lines of logic: ```ts const [hovering, setHovering] = useState(null); const [popoverLeft, setPopoverLeft] = useState(null); const [popoverHeight, setPopoverHeight] = useState(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:

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 if you like. Here is where I started:

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

Screenshot of a

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, and used the Builder.io plugin to convert them to React + Tailwind code using Visual Copilot.

By just clicking Generate , we get launched into 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:

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

I then import that into my page:

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

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:

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:

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:

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.

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 */
}

Note: There are better ways for managing state than repeated useState hooks like this, such as using reducers, libraries, or custom hooks, 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.

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.

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:

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.

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:

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

I’m going to go back to our Figma design and use the Builder.io figma plugin to convert each of the popover menus to React + Tailwind code.

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

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.

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:

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:

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.

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:

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.

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:

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:

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:

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.

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.

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:

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:

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:

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!

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:

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

Read the full post on the Builder.io blog

Top comments (4)

Collapse
 
adaptive-shield-matrix profile image
Adaptive Shield Matrix

I, somehow, do not like the animations, since it slows down/reduces responsiveness.

Seems like a copy of or similar/identical to
ui.shadcn.com/docs/components/navi...

Why does it not respect prefers-reduced-motion?
developer.mozilla.org/en-US/docs/W...

Heavy usage of hover does not seem mobile friendly.

Collapse
 
seandinan profile image
Sean Dinan

Not sure why you think it seems like a copy.

It looks like shadecn-ui was first released in April this year, while Stripe has been pretty well known for their navigation menu since at least 2018 (like this repo from back then that made a clone of it). I remember Stripe getting a lot of praise for their UI design back then.

Collapse
 
seandinan profile image
Sean Dinan

Nice walk-through!

Visual Copilot definitely looks interesting -- I'll have to give a try at some point. Already using Figma, React, and Tailwind anyways :)

Collapse
 
amarmujak23 profile image
Amar Mujak

Very Impressive!