Active Link Animation with Tailwind CSS and Framer Motion
When designing a one-page website, it’s often helpful to provide users with a visual indicator of their current location on the page. One elegant way to achieve this is by implementing an animated active link in your navigation menu.
This tutorial will guide you through creating a sliding active link animation using Next.js, Tailwind CSS, and Framer Motion. We’ll develop a navigation menu where the active link is highlighted with a sliding background.
Let’s get started!
Setting up the project
We’ll use Next.js with the App Router for this project. Here’s how we’ll structure our main components:
- A page component to hold everything together
- A navigation provider to manage the active link state
- A navigation menu component
- A section component for each part of the page
Let’s break down each part.
The page component
In the page.tsx
file, we’ll define our page sections:
const sections = [
{
title: "Home",
slug: "home"
},
{
title: "Customers",
slug: "customers"
},
{
title: "Partners",
slug: "partners"
},
{
title: "Team",
slug: "team"
}
];
Then, we’ll set up our main page component:
export default function SlidingActiveLinkPage() {
return (
<main className="relative flex min-h-screen flex-col overflow-hidden bg-slate-50">
<NavProvider>
<NavigationMenu links={sections} />
<div className="w-full max-w-5xl mx-auto px-4 md:px-6">
{sections.map((section) => (
<Section key={section.slug} section={section} />
))}
</div>
</NavProvider>
</main>
);
}
This component serves as the main structure for our one-page website. It includes the NavigationMenu
and Section
client components, both wrapped in a NavProvider
, that allows us to manage the state of the active section across different components. By using a context provider, we can avoid prop drilling and make the active link state accessible to both the navigation menu and the individual sections.
The navigation provider
The NavProvider
is a simple context provider that manages the active link state:
"use client";
import { createContext, Dispatch, SetStateAction, useContext, useState } from "react";
type ContextProps = {
activeLink: string,
setActiveLink: Dispatch>,
}
const NavContext = createContext({
activeLink: "",
setActiveLink: (): string => "",
})
export default function NavProvider({
children
}: {
children: React.ReactNode
}) {
const [activeLink, setActiveLink] = useState("")
return (
{children}
)
}
export const useNavProvider = () => useContext(NavContext)
This allows any child component to access and update the active link state, this way:
const { activeLink, setActiveLink } = useNavProvider();
The section component
Each section of our page is represented by a Section
component:
"use client";
import { useRef, useEffect } from "react";
import { useNavProvider } from "./nav-provider";
import { useInView } from "framer-motion";
export default function PageSection({
section,
}: {
section: { title: string; slug: string };
}) {
const ref = useRef(null);
const { setActiveLink } = useNavProvider();
const isInView = useInView(ref, {
margin: "-50% 0px -50% 0px",
});
useEffect(() => {
if (isInView) {
setActiveLink(section.slug);
}
}, [isInView]);
return (
<section
id={section.slug}
ref={ref}
className="h-screen flex justify-center items-center"
>
<h2 className="text-4xl font-bold text-slate-300">{section.title}</h2>
</section>
);
}
The key part here is the useInView
hook from Framer Motion. It detects when the section is in the middle of the viewport and updates the active link accordingly.
The navigation menu component
The NavigationMenu
component does several important things:
- It uses the
NavProvider
to get and set the active link. - It creates a
motion.div
that slides behind the active link with a spring animation. - It updates the position and size of this div whenever the active link changes.
- It checks the URL hash on load to set the initial active link.
Here’s the code:
"use client";
import { useRef, useState, useEffect } from "react";
import { useNavProvider } from "./nav-provider";
import Link from "next/link";
import { motion } from "framer-motion";
export default function NavigationMenu({
links,
}: {
links: { title: string; slug: string }[];
}) {
const { activeLink, setActiveLink } = useNavProvider();
const activeLinkRef = useRef<HTMLAnchorElement>(null);
const [animationProps, setAnimationProps] = useState({
left: 0,
width: 0,
});
// check if the url has a hash and if so, set the active link
useEffect(() => {
const url = window.location.hash;
if (url) {
const link = url.replace("#", "");
setActiveLink(link);
}
}, []);
// update the position and width of the active link underline
useEffect(() => {
const updateActiveLink = () => {
if (activeLinkRef.current) {
const { width } = activeLinkRef.current.getBoundingClientRect();
const left = activeLinkRef.current.offsetLeft;
setAnimationProps({
left,
width,
});
}
};
updateActiveLink();
window.addEventListener('resize', updateActiveLink);
return () => {
window.removeEventListener('resize', updateActiveLink);
};
}, [activeLink]);
return (
<header className="fixed top-2 md:top-6 w-full z-30">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="flex h-14 w-full items-center justify-between gap-3 rounded-full border border-gray-100 bg-white px-3 shadow-lg shadow-black/[0.04]">
<div className="flex flex-1 items-center">
<a
className="ml-0.5 inline-flex text-indigo-400 hover:text-indigo-500"
href="#0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
fill="none"
>
<path
className="fill-current"
fillRule="evenodd"
d="m14.862 0 2.763.738-2.073 7.709L21.67 2.35l2.022 2.015-5.656 5.637 8.475-2.263.74 2.753-7.726 2.063L28 14.82l-.74 2.753-7.672-2.05c.095-.412.146-.842.146-1.284 0-3.149-2.561-5.7-5.72-5.7a5.702 5.702 0 0 0-5.572 6.994L0 13.276l.74-2.753 7.726 2.063-6.204-6.183 2.023-2.016 5.656 5.637L7.67 1.58l2.762-.737 2.102 7.817L14.862 0Zm3.294 18.167a5.683 5.683 0 0 0 1.423-2.612l6.157 6.136-2.022 2.015-5.558-5.539Zm-.053.059a5.72 5.72 0 0 1-2.556 1.506l2.022 7.522 2.763-.738-2.23-8.29Zm-4.092 1.712c.493 0 .972-.062 1.428-.179L13.223 28l-2.762-.738 2.024-7.529c.486.134.998.205 1.526.205Zm-1.623-.232a5.721 5.721 0 0 1-2.512-1.528L4.305 23.73l2.022 2.016 6.06-6.04Zm-3.941-4.158a5.682 5.682 0 0 0 1.387 2.58L1.49 20.356l-.74-2.753 7.697-2.055Z"
clipRule="evenodd"
/>
</svg>
</a>
</div>
<nav className="relative flex justify-center">
<motion.div
className="absolute left-0 inset-y-0 bg-indigo-100 rounded-full"
aria-hidden="true"
animate={{
...animationProps,
}}
transition={{ type: "spring", duration: 0.5 }}
></motion.div>
<ul className="relative flex flex-wrap items-center gap-3 text-sm font-medium md:gap-8">
{links.map((link) => (
<li key={link.slug}>
<Link
href={`#${link.slug}`}
ref={activeLink === link.slug ? activeLinkRef : null}
className={`inline-flex rounded-full px-3 py-1.5 text-slate-500 hover:text-indigo-500 [&.active]:text-indigo-600 ${activeLink === link.slug ? "active" : ""}`}
>
{link.title}
</Link>
</li>
))}
</ul>
</nav>
<div className="flex flex-1 items-center justify-end">
<a
className="inline-flex justify-center whitespace-nowrap rounded-full bg-indigo-500 px-3 py-1.5 text-sm font-medium text-white shadow transition-colors hover:bg-indigo-600 focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300"
href="#0"
>
Sign up
</a>
</div>
</div>
</div>
</header>
);
}
The result
With these components in place, we’ve created a nice animated navigation menu that highlights the current section as users scroll through the page.
This technique can be adapted for various uses, such as creating scrollspy components for documentation sidebars or any other scenario where you need to visually represent the user’s current position in a long-form content page.