Craft Collection

Animated Navbar

Last updated: Sep 15, 2024

A navbar with two independent highlights: a soft one that follows the cursor, and a stronger one that marks the selected item. Neither cuts — both glide, because every item renders the same layoutId. When the item holding that id unmounts and another mounts with it, Framer Motion treats the two as one element moving and tweens between them.

Prerequisites

Created in Next.js with TypeScript. Styled with TailwindCSS. Animated with Framer Motion.


Note: don't sink the highlights with a negative z-index. relative alone creates no stacking context, so z-[-1] drops them behind the nav's own background and nothing renders. Paint them before the label and lift the label with relative z-10 instead.

Preview


Hover across the items to move the soft highlight, and click to move the selected one. The preview tracks selection in local state so the transition is visible; the component below uses usePathname(), so the selected pill follows the current route.

Code

tsx
'use client';
 
import { useState } from 'react';
import { motion } from 'framer-motion';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
 
const links = [
  { name: 'Home', href: '/' },
  { name: 'Portfolio', href: '/portfolio' },
  { name: 'Blogs', href: '/blogs' },
  { name: 'Craft', href: '/craft' },
];
 
const spring = { type: 'spring', stiffness: 380, damping: 30 } as const;
 
export default function NavBar() {
  const pathname = usePathname();
  const [hoveredHref, setHoveredHref] = useState<string | null>(null);
 
  return (
    <nav className="flex items-center bg-white p-4 dark:bg-gray-800">
      <ul
        className="flex items-center"
        onMouseLeave={() => setHoveredHref(null)}
      >
        {links.map((link) => {
          const isActive = pathname === link.href;
 
          return (
            <li key={link.href}>
              <Link
                href={link.href}
                onMouseEnter={() => setHoveredHref(link.href)}
                onFocus={() => setHoveredHref(link.href)}
                aria-current={isActive ? 'page' : undefined}
                className={`relative block px-3 py-2 transition-colors duration-300 ${
                  isActive
                    ? 'text-gray-900 dark:text-gray-50'
                    : 'text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-50'
                }`}
              >
                {hoveredHref === link.href && (
                  <motion.span
                    layoutId="nav-hover"
                    className="absolute inset-0 rounded-md bg-gray-100 dark:bg-gray-700/50"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={spring}
                  />
                )}
                {isActive && (
                  <motion.span
                    layoutId="nav-active"
                    className="absolute inset-0 rounded-md bg-gray-200 dark:bg-gray-700"
                    transition={spring}
                  />
                )}
                <span className="relative z-10">{link.name}</span>
              </Link>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

The two highlights are separate layers with separate ids, so they move independently — hovering never disturbs the selected pill. Order matters: the hover span is rendered first and the active span second, so the selected item still reads as selected while you hover it.

onMouseLeave sits on the <ul> rather than each item, so sweeping across the nav hands the highlight from one item to the next without clearing it in between. onFocus mirrors onMouseEnter so the highlight tracks keyboard tabbing too.

Tune the glide with stiffness and damping — lower stiffness makes it looser, higher damping settles it sooner.

Inspired by Paco Coursey