How to make a Vercel-like navigation using React and Framer Motion

So I was reading the new Framer Motion documentation the other day and I stumbled upon this gem called layout animations.

Layout animations allow you to transition between any CSS property and even between different elements! Today we'll use the latter to create a navigation animation similar to Vercel's using Framer Motion's layoutId prop.

Here's the final result:

First, let's create a basic navigation without animations:

export default function Navigation() {
  return (
    <nav className="mx-auto w-fit rounded-md bg-zinc-950 px-4">
      <div className="relative flex items-center">
        <button className="relative cursor-pointer">
          <div className="text-sm/3.5 relative z-10 select-none px-3 py-4 text-neutral-400 transition-colors hover:text-white">
            Home
          </div>
          <div className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800" />
        </button>
        <button className="relative cursor-pointer">
          <div className="text-sm/3.5 relative z-10 select-none px-3 py-4 text-neutral-400 transition-colors hover:text-white">
            About
          </div>
        </button>
        <button className="relative cursor-pointer">
          <div className="text-sm/3.5 relative z-10 select-none px-3 py-4 text-neutral-400 transition-colors hover:text-white">
            Blog
          </div>
        </button>
        <button className="relative cursor-pointer">
          <div className="text-sm/3.5 relative z-10 select-none px-3 py-4 text-neutral-400 transition-colors hover:text-white">
            Contact
          </div>
        </button>
      </div>
    </nav>
  );
}

Copy and paste this code and you should end up with this:

Let's clean it up by extracting the tab data inside of a variable:

const tabs = [
  { id: "1", label: "Home" },
  { id: "2", label: "About" },
  { id: "3", label: "Blog" },
  { id: "4", label: "Contact" },
];

export default function Navigation() {
  return (
    <nav className="mx-auto w-fit rounded-md bg-zinc-950 px-4">
      <div className="relative flex items-center">
        {tabs.map(({ id, label }) => (
          <button key={id} className="relative cursor-pointer">
            <div className="text-sm/3.5 relative z-10 select-none px-3 py-4 text-neutral-400 transition-colors hover:text-white">
              {label}
            </div>
            <div className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800" />
          </button>
        ))}
      </div>
    </nav>
  );
}

Preview after cleanup with tab data:

Now let's add hover effects and active states:

export default function Navigation() {
  const [hoveredTab, setHoveredTab] = useState(null);
  const [activeTab, setActiveTab] = useState(tabs[0].id);

  return (
    <nav className="mx-auto w-fit rounded-md bg-zinc-950 px-4">
      <div className="relative flex items-center">
        {tabs.map(({ id, label }) => (
          <button
            key={id}
            onClick={() => setActiveTab(id)}
            onMouseOver={() => setHoveredTab(id)}
            onMouseLeave={() => setHoveredTab(null)}
            className="relative cursor-pointer"
          >
            <div
              className={`text-sm/3.5 relative z-10 select-none px-3 py-4 transition-colors ${
                activeTab === id
                  ? "text-white"
                  : "text-neutral-400 hover:text-white"
              }`}
            >
              {label}
            </div>
            {hoveredTab === id ? (
              <div className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800" />
            ) : null}
            {activeTab === id ? (
              <div className="absolute inset-x-0 bottom-0 h-0.5 bg-white" />
            ) : null}
          </button>
        ))}
      </div>
    </nav>
  );
}

Preview with active and hover states:

Adding Animations:

import { motion } from "framer-motion";

Replace the divs we want to animate with <motion.div> like this:

  <motion.div className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800" />
  <motion.div className="absolute inset-x-0 bottom-0 h-0.5 bg-white" />

Now here's the magic part. Let's give those two elements a unique layoutId:

<motion.div
  layoutId="bg-color"
  className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800"
/>
<motion.div
  layoutId="bg-underline"
  className="absolute inset-x-0 bottom-0 h-0.5 bg-white"
/>

And we already have the animation working!

Now for the final touch let's add some transitions to our divs:

<motion.div
  layoutId="bg-color"
  transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
  className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800"
/>
<motion.div
  layoutId="bg-underline"
  transition={{ duration: 0.15 }}
  className="absolute inset-x-0 bottom-0 h-0.5 bg-white"
/>

And we are done.

Framer Motion is incredibly powerful. We made the whole animation work just by replacing two divs with two <motion.div> elements and adding two layoutId props to them. I encourage you to read the Framer Motion documentation and learn more fun things you can do with it.

Source code:

import { motion } from "framer-motion";
import { useState } from "react";

const tabs = [
  { id: "1", label: "Home" },
  { id: "2", label: "About" },
  { id: "3", label: "Blog" },
  { id: "4", label: "Contact" },
];

export default function Navigation() {
  const [activeTab, setActiveTab] = useState(tabs[0].id);
  const [hoveredTab, setHoveredTab] = useState(null);

  return (
    <nav className="mx-auto w-fit rounded-md bg-zinc-950 px-4">
      <div className="relative flex items-center">
        {tabs.map(({ id, label }) => (
          <button
            key={id}
            onClick={() => setActiveTab(id)}
            onMouseOver={() => setHoveredTab(id)}
            onMouseLeave={() => setHoveredTab(null)}
            className="relative cursor-pointer"
          >
            <div
              className={`text-sm/3.5 relative z-10 select-none px-3 py-4 transition-colors ${
                activeTab === id
                  ? "text-white"
                  : "text-neutral-400 hover:text-white"
              }`}
            >
              {label}
            </div>
            {hoveredTab === id ? (
              <motion.div
                layoutId="bg-color"
                className="absolute inset-x-0 top-3.5 h-8 rounded bg-zinc-800"
                transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
              />
            ) : null}
            {activeTab === id ? (
              <motion.div
                layoutId="bg-underline"
                className="absolute inset-x-0 bottom-0 h-0.5 bg-white"
                transition={{ duration: 0.15 }}
              />
            ) : null}
          </button>
        ))}
      </div>
    </nav>
  );
}