How to create Vercel-style Navigation Animation

Abubakar Balogun
7 min readFeb 15, 2024

--

If you have been using Vercel for deployment for a while, you might have noticed a subtle animation when you hover or click on the sub-header on the dashboard’s page. It is a direction-aware animation that follows the direction of your mouse from the previous position to the destination position. Unlike intrusive animations that can complicate the user experience, this particular animation adds a touch of elegance without disrupting the overall flow. In this article, we will walk through the process of recreating this direction-aware animation inspired by Vercel in React using the Framer Motion library. The beauty of this approach is that it allows us to achieve the desired effect without resorting to manual DOM manipulations or introducing unnecessary side effects.

Getting started

To begin, create a new React app using Vite with the following command:

npm create vite vercel-navgition

After setting up the project, navigate to the project directory and start the development server:

cd vercel-navigation

npm run dev

Next, install the required dependencies — Framer Motion and React Router:

npm install framer-motion react-router-dom

Configure React Roter

Next, we configure React Router to handle navigation within our application. Open the main.jsx file and update it as follows:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

File structure

.
└── vercel-navigation/
├── public
├── assets
└── src/
├── components/
│ ├── Header.jsx
│ └── SubHeader.jsx
├── App.jsx
├── index.css
├── main.jsx
└── ...

For this project, I created two components, a Header and a Subheader. This is because I want it to resemble Vercel dashboard page. You necessarily don’t need a Header component, as most animation will be done in the Subheader component.

Header component:

const Header = () => {
return (
<header className="container mx-auto flex justify-between items-center p-4 text-white">
<div id="logo" className="text-2xl font-bold">
Fercel
</div>
<div className="flex gap-3 items-center">
<div
id="account"
className="text-lg rounded-full p-1 border border-gray-500"
>
<svg
xmlns="<http://www.w3.org/2000/svg>"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
/>
</svg>
</div>
<div>
<div
id="account"
className="text-lg rounded-full p-4 bg-gradient-to-tr from-blue-500 to-green-500"
></div>
</div>
</div>
</header>
);
};
export default Header;

The Header component represents the top section of our application, featuring a logo and an account indicator. I included the header component to mimic the Vercel dashboard page, although there is no other specific reason for its inclusion. Feel free to ignore it if you prefer.

SubHeader Component

import { Link } from "react-router-dom";

const SubHeader = () => {
const navItems = [
{ id: "overview", label: "Overview" },
{ id: "integration", label: "Integration" },
{ id: "analytics", label: "Analytics" },
{ id: "notifications", label: "Notifications" },
{ id: "billing", label: "Billing" },
{ id: "settings", label: "Settings" },
{ id: "security", label: "Security" },
];
return (
<nav className="container mx-auto flex justify-between items-center text-white border-b border-gray-500">
<ul className="flex">
{navItems.map((nav) => (
<li
key={nav.id}
className="relative px-4 py-2"
>
<Link
href={`/${nav.id}`}
className="text-gray-200"
>
{nav.label}
</Link>
</li>
))}
</ul>
</nav>
);
};
export default SubHeader;

The SubHeader component is where the animation takes place. It renders a list of navigation items, each with corresponding links. In this component, we create an array of navigation items navItems, map through the array to generate a list of items with links, and apply styling for the desired appearance.

On the browser, you’ll see something like this:

Creating Animation

Observing the animation closely, you’ll notice two distinct animations in the navigation: the hover animation and the active animation. The hover animation is triggered when you hover over the navigation links, following the mouse direction. In contrast, the active animation is only triggered when you click on a navigation link, causing it to slide from the previous active link to the current one. Let’s break it down step by step.

Implementing Hover animation.

import { useState } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";

const SubHeader = () => {
const [hoveredNavItem, setHoveredNavItem] = useState(null);
const navItems = [
{ id: "overview", label: "Overview" },
{ id: "integration", label: "Integration" },
{ id: "settings", label: "Settings" },
{ id: "profile", label: "Profile" },
{ id: "analytics", label: "Analytics" },
{ id: "notifications", label: "Notifications" },
{ id: "billing", label: "Billing" },
{ id: "security", label: "Security" },
];
const handleNavItemHover = (navId: string) => {
setHoveredNavItem(navId);
};
const handleNavItemMouseLeave = () => {
setHoveredNavItem(null);
};
return (
<nav className="container mx-auto flex justify-between items-center text-white border-b border-gray-500">
<ul className="flex">
{navItems.map((nav) => (
<li
key={nav.id}
onMouseMove={() => handleNavItemHover(nav.id)}
onMouseLeave={handleNavItemMouseLeave}
className="relative px-4 py-2"
>
<Link
to={`/${nav.id}`}
className="relative z-20 text-white
"
>
{nav.label}
</Link>
{hoveredNavItem === nav.id && (
<motion.span
layoutId="hover"
transition={{ type: "spring", duration: 0.4 }}
className="absolute inset-0 bg-gray-600/50 rounded-lg"
></motion.span>
)}
</li>
))}
</ul>
</nav>
);
};
export default SubHeader;

We use a useState hook to track the currently hovered navigation item. Two event handlers are defined: handleNavItemHover is triggered when hovering over a navigation item, updating the hoveredNavItem state with the corresponding navigation item's id. handleNavItemMouseLeave resets hoveredNavItem to null when the mouse leaves a navigation item.

Each list item contains a link (<Link>) directing to a specific path. Upon hovering over a navigation item, a transparent overlay (<motion.span>) with a hover animation is displayed using the motion library. The layoutId property ensures smooth transitions, while the transition property defines the animation's type and duration. Positioned absolutely, the transparent overlay covers the entire list item, creating an engaging hover effect.

Implementing Active animation

import { useState } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";

const SubHeader = () => {
const [activeNavItem, setActiveNavItem] = useState(null);
const [hoveredNavItem, setHoveredNavItem] = useState(null);
const navItems = [
{ id: "overview", label: "Overview" },
{ id: "integration", label: "Integration" },
{ id: "settings", label: "Settings" },
{ id: "profile", label: "Profile" },
{ id: "analytics", label: "Analytics" },
{ id: "notifications", label: "Notifications" },
{ id: "billing", label: "Billing" },
{ id: "security", label: "Security" },
];
const handleNavItemClick = (navId) => {
setActiveNavItem(navId);
setHoveredNavItem(null);
};
const handleNavItemHover = (navId) => {
setHoveredNavItem(navId);
};
const handleNavItemMouseLeave = () => {
setHoveredNavItem(null);
};
return (
<nav className="container mx-auto flex justify-between items-center text-white border-b border-gray-500">
<ul className="flex">
{navItems.map((nav) => (
<li
key={nav.id}
onClick={()=> handleNavItemClick(nav.id)}
onMouseMove={() => handleNavItemHover(nav.id)}
onMouseLeave={handleNavItemMouseLeave}
className="relative px-4 py-2"
>
<Link
to={`/${nav.id}`}
className={`relative z-20 ${
activeNavItem === nav.id ? "text-gray-200" : "text-gray-500"
}`}
>
{nav.label}
</Link>
{hoveredNavItem === nav.id && (
<motion.span
layoutId="hover"
transition={{ type: "spring", duration: 0.4 }}
className="absolute inset-0 bg-gray-600/50 rounded-lg"
></motion.span>
)}
{activeNavItem === nav.id && (
<motion.span
layoutId="active"
transition={{ type: "spring", duration: 0.5 }}
className="z-10 absolute inset-0 border-b-2 "
></motion.span>
)}
</li>
))}
</ul>
</nav>
);
};
export default SubHeader;

Similar to the hover animation implementation, we introduce a useState hook to track the state of the active link. When a navigation item is clicked, the handleNavItemClick function is called, updating the activeNavItem state to the clicked item’s ID. We maintain consistency with the hover animation, ensuring smooth transitions and visual cues for user interaction.

Storing the state in the URL

To maintain the active state even after a page reload or component mount, we need a solution beyond useState. One effective approach is to synchronize the active state with the URL. By doing so, the state persists across page reloads and component mounts.

We utilize React Router’s useLocation hook to access the current URL. Then, we employ the useEffect hook to update the active state whenever the URL changes. This method ensures that the active state remains synchronized with the URL, providing a more robust and efficient solution compared to initializing the active state with useState.

import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion } from "framer-motion";

const SubHeader = () => {
const location = useLocation();
const [activeNavItem, setActiveNavItem] = useState(null);
const [hoveredNavItem, setHoveredNavItem] = useState(null);

const navItems = [
{ id: "overview", label: "Overview" },
{ id: "integration", label: "Integration" },
{ id: "settings", label: "Settings" },
{ id: "profile", label: "Profile" },
{ id: "analytics", label: "Analytics" },
{ id: "notifications", label: "Notifications" },
{ id: "billing", label: "Billing" },
{ id: "security", label: "Security" },
];

const handleNavItemHover = (navId: string) => {
setHoveredNavItem(navId);
};

const handleNavItemMouseLeave = () => {
setHoveredNavItem(null);
};

useEffect(() => {
// Update activeNavItem when location changes
setActiveNavItem(location.pathname.substring(1));
}, [location.pathname]);

return (
<nav className="container mx-auto flex justify-between items-center text-white border-b border-gray-500">
<ul className="flex">
{navItems.map((nav) => (
<li
key={nav.id}
onMouseMove={() => handleNavItemHover(nav.id)}
onMouseLeave={handleNavItemMouseLeave}
className="relative px-4 py-2"
>
<Link
to={`/${nav.id}`}
className={`relative z-20 ${
activeNavItem === nav.id ? "text-gray-200" : "text-gray-500"
}`}
>
{nav.label}
</Link>
{hoveredNavItem === nav.id && (
<motion.span
layoutId="hover"
transition={{ type: "spring", duration: 0.4 }}
className="absolute inset-0 bg-gray-600/50 rounded-lg"
></motion.span>
)}
{activeNavItem === nav.id && (
<motion.span
layoutId="active"
transition={{ type: "spring", duration: 0.5 }}
className="z-10 absolute inset-0 border-b-2 "
></motion.span>
)}
</li>
))}
</ul>
</nav>
);
};

export default SubHeader;

We leverage React Router’s functionality and the useEffect hooks to synchronize and persist the animation state even after page refresh.

Conclusion

This tutorial has showcased the implementation of direction-aware animation inspired by Vercel. By following the steps outlined here, you can enhance the visual appeal and interactivity of your web applications. The integration of Framer motion and React Router libraries allows for seamless animation transitions based on user interaction. I trust that you have found this tutorial informative and insightful.

--

--

Abubakar Balogun
Abubakar Balogun

Written by Abubakar Balogun

I share insights on Software development and other subjects that pique my interest.

No responses yet