Flip Hover Navbar
mega-menuAn animated mega-menu navigation with flip-text hover effects and rich dropdown panels. Ideal for SaaS products and marketing sites.
#mega-menu#animated#hover-effect#saas
Preview
flip-hover-navbar
Interactive Preview
Brand
Hover over 'Products' or 'Company' to trigger the mega menu.mega-menu
Installation
Add this navbar component directly to your project using the shadcn CLI:
$npx shadcn@latest add https://navcn-phi.vercel.app/r/flip-hover-navbar.json
The component source code is added directly to your project so you can customize it freely.
Source Code
src/components/previews/flip-hover-navbar.tsxtsx
"use client";
import React, { useCallback, useMemo, useState, useRef } from "react";
import { motion, AnimatePresence, Variants } from "motion/react";
import { Menu, X, ArrowUpRight, ChevronRight } from "lucide-react";
// ── Types ──────────────────────────────────────────────────────────────────
interface NavItem {
name: string;
desc: string;
}
interface NavSection {
title: string;
items: NavItem[];
}
interface HoverContent {
title: string;
subtitle: string;
sections: NavSection[];
}
interface NavLink {
id: number;
name: string;
hoverContent: HoverContent;
}
interface FlipHoverNavbarProps {
links?: NavLink[];
brandName?: string;
showButtons?: boolean;
className?: string;
}
interface HoverButtonProps {
children: string;
icon?: boolean;
}
interface HoverCardProps {
content: HoverContent;
linkId: number;
activeSublink: string | null;
onSublinkClick: (sublinkKey: string) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
// ── Constants ──────────────────────────────────────────────────────────────
const DURATION = 0.25;
const STAGGER = 0.02;
// ── Sub-components ─────────────────────────────────────────────────────────
const HoverButton = React.memo(function HoverButton({
children,
icon = false,
}: HoverButtonProps) {
const buttonVariants: Variants = {
initial: { y: 0 },
hovered: { y: "-100%" },
};
const buttonVariantsBottom: Variants = {
initial: { y: "100%" },
hovered: { y: 0 },
};
const iconVariants: Variants = {
initial: { x: 0 },
hovered: { x: 4 },
};
return (
<motion.button
initial="initial"
whileHover="hovered"
whileTap={{ scale: 0.97 }}
className="px-4 py-2 group font-medium hidden md:flex bg-foreground text-background rounded-full transition-all duration-200 overflow-hidden h-9 items-center gap-2 text-xs cursor-pointer"
>
<motion.span
className="relative block overflow-hidden whitespace-nowrap"
style={{ lineHeight: 1 }}
>
<div>
{children.split("").map((l, i) => (
<motion.span
key={`t-${i}`}
variants={buttonVariants}
transition={{ duration: DURATION, ease: "easeInOut", delay: STAGGER * i }}
className="inline-block"
>
{l === " " ? "\u00A0" : l}
</motion.span>
))}
</div>
<div className="absolute inset-0">
{children.split("").map((l, i) => (
<motion.span
key={`b-${i}`}
variants={buttonVariantsBottom}
transition={{ duration: DURATION, ease: "easeInOut", delay: STAGGER * i }}
className="inline-block"
>
{l === " " ? "\u00A0" : l}
</motion.span>
))}
</div>
</motion.span>
{icon && (
<motion.div
variants={iconVariants}
transition={{ duration: 0.18 }}
className="bg-background text-foreground p-1 rounded-full"
>
<ArrowUpRight size={12} className="group-hover:rotate-45 transition-transform" />
</motion.div>
)}
</motion.button>
);
});
const HoverCard = React.memo(function HoverCard({
content,
linkId,
activeSublink,
onSublinkClick,
onMouseEnter,
onMouseLeave,
}: HoverCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 6, scale: 0.97 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="absolute top-full left-1/2 -translate-x-1/2 pt-2 w-[calc(100vw-48px)] max-w-[560px] z-50 pointer-events-auto"
role="menu"
aria-label={content.title}
>
<div className="w-full bg-background border border-border/80 rounded-2xl shadow-xl p-6 sm:p-8">
<div className="mb-5 border-b border-border/40 pb-4">
<div className="flex items-center gap-2.5 mb-1">
<div className="w-4 h-4 bg-foreground rounded-full flex items-center justify-center">
<div className="w-1.5 h-1.5 bg-background rounded-full" />
</div>
<h3 className="text-base font-bold text-foreground">{content.title}</h3>
</div>
<p className="text-xs text-muted-foreground pl-6">{content.subtitle}</p>
</div>
<div className="space-y-5">
{content.sections.map((section, idx) => (
<div key={idx}>
<h4 className="text-[11px] font-semibold text-muted-foreground/70 mb-3 uppercase tracking-wider">
{section.title}
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{section.items.map((item, itemIdx) => {
const sublinkKey = `${linkId}-${idx}-${itemIdx}`;
const isActive = activeSublink === sublinkKey;
return (
<div
key={itemIdx}
className="group cursor-pointer p-2.5 rounded-xl hover:bg-muted/60 transition-all border border-transparent hover:border-border/40"
onClick={() => onSublinkClick(sublinkKey)}
>
<div className="flex gap-2.5 items-start">
<div
className={`w-3.5 h-3.5 rounded-full flex items-center justify-center mt-0.5 transition-colors flex-shrink-0 ${
isActive ? "bg-foreground" : "bg-muted-foreground/30 group-hover:bg-foreground/60"
}`}
>
<div className="w-1 h-1 bg-background rounded-full" />
</div>
<div>
<h5
className={`text-xs font-semibold transition-colors ${
isActive ? "text-foreground font-bold" : "text-foreground/90 group-hover:text-foreground"
}`}
>
{item.name}
</h5>
<p className="text-[11px] text-muted-foreground leading-tight mt-0.5">{item.desc}</p>
</div>
</div>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
</motion.div>
);
});
// ── Main Component ─────────────────────────────────────────────────────────
export default function FlipHoverNavbar({
links: propLinks,
brandName = "Brand",
showButtons = true,
className = "",
}: FlipHoverNavbarProps) {
const defaultLinks: NavLink[] = useMemo(
() => [
{
id: 1,
name: "Products",
hoverContent: {
title: "Product Suite",
subtitle: "Explore components, design tools & APIs",
sections: [
{
title: "Overview",
items: [
{ name: "Intro", desc: "Short introduction & overview" },
{ name: "Docs", desc: "API, guides and examples" },
],
},
{
title: "Ecosystem",
items: [
{ name: "Pricing", desc: "Plans for teams & individuals" },
{ name: "Integrations", desc: "Third-party developer tools" },
],
},
],
},
},
{
id: 2,
name: "Company",
hoverContent: {
title: "Company",
subtitle: "Learn more about our team and mission",
sections: [
{
title: "About Us",
items: [
{ name: "Team", desc: "Meet our builders & designers" },
{ name: "Careers", desc: "We're hiring across engineering" },
],
},
{
title: "Resources",
items: [
{ name: "Blog", desc: "Latest news and articles" },
{ name: "Press Kit", desc: "Logos & media assets" },
],
},
],
},
},
{
id: 3,
name: "Partners",
hoverContent: {
title: "Partners",
subtitle: "Join our partner network",
sections: [
{
title: "Get Involved",
items: [
{ name: "Join Network", desc: "Become an official partner" },
{ name: "Support", desc: "24/7 dedicated partner help" },
],
},
],
},
},
],
[]
);
const links = propLinks && propLinks.length ? propLinks : defaultLinks;
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [activeLink, setActiveLink] = useState<number | null>(null);
const [activeSublink, setActiveSublink] = useState<string | null>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openMobileIndex, setOpenMobileIndex] = useState<number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleMouseEnter = useCallback((id: number) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setHoveredLink(id);
}, []);
const handleMouseLeave = useCallback(() => {
timeoutRef.current = setTimeout(() => {
setHoveredLink(null);
}, 180);
}, []);
const toggleMobileMenu = useCallback(() => setMobileMenuOpen((s) => !s), []);
const closeMobileMenu = useCallback(() => setMobileMenuOpen(false), []);
const toggleMobileIndex = useCallback(
(id: number) => setOpenMobileIndex((prev) => (prev === id ? null : id)),
[]
);
const handleSublinkClick = useCallback(
(sublinkKey: string, linkId: number) => {
setActiveSublink(sublinkKey);
setActiveLink(linkId);
},
[]
);
const activeHoveredContent = useMemo(() => {
if (hoveredLink === null) return null;
return links.find((l) => l.id === hoveredLink) || null;
}, [hoveredLink, links]);
return (
<div className={`w-full relative py-2 ${className}`}>
<div className="max-w-6xl mx-auto px-2 sm:px-4">
{/* Main Navbar Pill Container */}
<div className="w-full bg-background border border-border/80 rounded-full shadow-sm px-4 sm:px-6 h-15 flex items-center justify-between relative z-40">
{/* Brand */}
<div className="flex items-center gap-2">
<div className="w-7 h-7 bg-foreground rounded-lg flex items-center justify-center">
<div className="w-3 h-3 bg-background rounded-xs" />
</div>
<span className="text-base font-bold tracking-tight text-foreground">{brandName}</span>
</div>
{/* Desktop Nav Links */}
<div className="hidden lg:flex items-center space-x-1 relative">
<div className="flex items-center bg-muted/50 rounded-full p-1 border border-border/40">
{links.map((link) => {
const isHovered = hoveredLink === link.id;
const isActive = activeLink === link.id;
return (
<button
key={link.id}
onMouseEnter={() => handleMouseEnter(link.id)}
onMouseLeave={handleMouseLeave}
onClick={() => {
setActiveLink(link.id);
setActiveSublink(null);
}}
className={`relative px-4 py-1.5 text-xs font-medium rounded-full transition-colors cursor-pointer ${
isHovered || isActive
? "text-foreground font-semibold"
: "text-muted-foreground hover:text-foreground"
}`}
>
{(isHovered || isActive) && (
<motion.div
layoutId="flipHoverPill"
className="absolute inset-0 bg-background rounded-full shadow-xs border border-border/60 -z-10"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
<span>{link.name}</span>
</button>
);
})}
</div>
</div>
{/* Right Action Buttons */}
<div className="hidden lg:flex items-center space-x-2">
{showButtons && <HoverButton icon>Get started</HoverButton>}
{showButtons && <HoverButton icon>Contact</HoverButton>}
</div>
{/* Mobile Toggle Button */}
<div className="lg:hidden">
<button
onClick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
className="p-2 rounded-full text-foreground hover:bg-muted transition-colors w-9 h-9 flex items-center justify-center cursor-pointer"
>
{mobileMenuOpen ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
{/* Mega Menu Dropdown Overlay */}
<AnimatePresence>
{activeHoveredContent && (
<HoverCard
key={activeHoveredContent.id}
content={activeHoveredContent.hoverContent}
linkId={activeHoveredContent.id}
activeSublink={activeSublink}
onSublinkClick={(sublinkKey) =>
handleSublinkClick(sublinkKey, activeHoveredContent.id)
}
onMouseEnter={() => handleMouseEnter(activeHoveredContent.id)}
onMouseLeave={handleMouseLeave}
/>
)}
</AnimatePresence>
{/* Mobile Menu Drawer */}
<AnimatePresence>
{mobileMenuOpen && (
<motion.div
key="mobile-drawer"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="lg:hidden mt-2 border border-border/80 rounded-2xl bg-background overflow-hidden shadow-lg"
>
<div className="p-4 space-y-2">
{links.map((link) => {
const isOpen = openMobileIndex === link.id;
return (
<div key={link.id} className="border-b border-border/30 last:border-none pb-2 last:pb-0">
<button
onClick={() => toggleMobileIndex(link.id)}
className="w-full flex items-center justify-between py-2 text-xs font-semibold text-foreground cursor-pointer"
>
<span>{link.name}</span>
<ChevronRight
size={14}
className={`transition-transform duration-200 ${
isOpen ? "rotate-90 text-foreground" : "text-muted-foreground"
}`}
/>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden pl-3 py-1 space-y-2"
>
{link.hoverContent?.sections?.map((section, sIdx) => (
<div key={sIdx}>
<span className="text-[10px] uppercase font-bold text-muted-foreground/60 block mb-1">
{section.title}
</span>
<div className="space-y-1">
{section.items.map((item, itIdx) => (
<button
key={itIdx}
onClick={() => {
handleSublinkClick(`${link.id}-${sIdx}-${itIdx}`, link.id);
closeMobileMenu();
}}
className="block text-left w-full text-xs text-muted-foreground hover:text-foreground py-1 cursor-pointer"
>
<span className="font-medium text-foreground">{item.name}</span> —{" "}
<span>{item.desc}</span>
</button>
))}
</div>
</div>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
This is the exact source file installed into your project. You own and control the code.