Flip Hover Navbar

mega-menu

An 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.

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.tsx
tsx
"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.