Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
  ArrowRight,
  BarChart3,
  BookOpen,
  Boxes,
  Building2,
  ChevronDown,
  CreditCard,
  FileCode2,
  GraduationCap,
  LifeBuoy,
  Menu,
  MessagesSquare,
  Rocket,
  ShieldCheck,
  ShoppingBag,
  Sparkles,
  Users,
  Workflow,
  X,
  Zap,
  type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";

export interface MegaItem {
  title: string;
  description: string;
  icon: LucideIcon;
  href?: string;
}

export interface MegaMenu {
  label: string;
  columns: { title: string; items: MegaItem[] }[];
  featured?: { eyebrow: string; title: string; description: string; cta: string; href?: string };
}

export interface NavbarMegaProps {
  brand?: string;
  brandHref?: string;
  menus?: MegaMenu[];
  links?: { label: string; href?: string }[];
  signIn?: { label: string; href?: string };
  cta?: { label: string; href?: string };
  /** Label of a menu to open initially (useful for demos/screenshots). */
  defaultOpen?: string;
  className?: string;
}

const EASE = [0.22, 1, 0.36, 1] as const;

const DEFAULT_MENUS: MegaMenu[] = [
  {
    label: "Product",
    columns: [
      {
        title: "Platform",
        items: [
          { title: "Analytics", description: "Dashboards that update in real time", icon: BarChart3 },
          { title: "Automations", description: "No-code workflows triggered by events", icon: Workflow },
          { title: "Integrations", description: "Connect 120+ tools in a click", icon: Boxes },
        ],
      },
      {
        title: "Capabilities",
        items: [
          { title: "AI assistant", description: "Ask questions about your data", icon: Sparkles },
          { title: "Performance", description: "Sub-100ms queries at any scale", icon: Zap },
          { title: "Security", description: "SSO, SCIM, audit logs, SOC 2", icon: ShieldCheck },
        ],
      },
    ],
    featured: {
      eyebrow: "Launch week",
      title: "Northwind 4.0 is here",
      description: "A rebuilt query engine, a new canvas and 40 other upgrades.",
      cta: "Read the announcement",
    },
  },
  {
    label: "Solutions",
    columns: [
      {
        title: "By team",
        items: [
          { title: "Product", description: "Understand adoption and retention", icon: Rocket },
          { title: "Growth", description: "Experiment and measure every funnel", icon: BarChart3 },
          { title: "Support", description: "Resolve issues with full context", icon: LifeBuoy },
        ],
      },
      {
        title: "By industry",
        items: [
          { title: "E-commerce", description: "Carts, cohorts and lifetime value", icon: ShoppingBag },
          { title: "Fintech", description: "Compliant analytics for payments", icon: CreditCard },
          { title: "Enterprise", description: "Governance for global teams", icon: Building2 },
        ],
      },
    ],
    featured: {
      eyebrow: "Customer story",
      title: "How Acme cut churn by 32%",
      description: "Their growth team replaced four tools with one workspace.",
      cta: "Read the case study",
    },
  },
  {
    label: "Resources",
    columns: [
      {
        title: "Learn",
        items: [
          { title: "Documentation", description: "Guides, recipes and API reference", icon: BookOpen },
          { title: "Academy", description: "Free courses with certificates", icon: GraduationCap },
          { title: "Changelog", description: "Everything we shipped this month", icon: FileCode2 },
        ],
      },
      {
        title: "Connect",
        items: [
          { title: "Community", description: "12k builders swapping ideas", icon: Users },
          { title: "Support", description: "Talk to a human in minutes", icon: MessagesSquare },
          { title: "Status", description: "Uptime and incident history", icon: ShieldCheck },
        ],
      },
    ],
    featured: {
      eyebrow: "Webinar · Oct 14",
      title: "Metrics that matter in 2027",
      description: "A live session with our data science team.",
      cta: "Save your seat",
    },
  },
];

export function NavbarMega({
  brand = "Northwind",
  brandHref = "#",
  menus = DEFAULT_MENUS,
  links = [
    { label: "Pricing", href: "#" },
    { label: "Enterprise", href: "#" },
  ],
  signIn = { label: "Sign in", href: "#" },
  cta = { label: "Start free", href: "#" },
  defaultOpen,
  className,
}: NavbarMegaProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [open, setOpen] = React.useState<string | null>(defaultOpen ?? null);
  const [dir, setDir] = React.useState(0);
  const [mobile, setMobile] = React.useState(false);
  const rootRef = React.useRef<HTMLElement>(null);
  const triggerRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
  const hoverTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  const idx = (label: string | null) => menus.findIndex((m) => m.label === label);
  const show = React.useCallback(
    (label: string | null) => {
      setOpen((prev) => {
        if (prev && label) setDir(Math.sign(menus.findIndex((m) => m.label === label) - menus.findIndex((m) => m.label === prev)));
        else setDir(0);
        return label;
      });
    },
    [menus],
  );

  const clearTimer = () => {
    if (hoverTimer.current) clearTimeout(hoverTimer.current);
  };
  const hoverOpen = (label: string) => {
    clearTimer();
    hoverTimer.current = setTimeout(() => show(label), open ? 0 : 90);
  };
  const hoverClose = () => {
    clearTimer();
    hoverTimer.current = setTimeout(() => show(null), 160);
  };

  React.useEffect(() => () => clearTimer(), []);

  const focusPanelFirst = () =>
    requestAnimationFrame(() => rootRef.current?.querySelector<HTMLElement>(`#${CSS.escape(`${uid}-panel`)} a`)?.focus());

  const onTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>, label: string) => {
    const i = idx(label);
    if (e.key === "ArrowDown") {
      e.preventDefault();
      show(label);
      focusPanelFirst();
    } else if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      e.preventDefault();
      const next = menus[(i + (e.key === "ArrowRight" ? 1 : -1) + menus.length) % menus.length];
      triggerRefs.current[next.label]?.focus();
      if (open) show(next.label);
    }
  };

  const onRootKey = (e: React.KeyboardEvent) => {
    if (e.key === "Escape") {
      if (open) {
        const label = open;
        show(null);
        triggerRefs.current[label]?.focus();
      }
      setMobile(false);
    }
  };

  const active = menus.find((m) => m.label === open) ?? null;

  return (
    <header
      ref={rootRef}
      onKeyDown={onRootKey}
      onBlur={(e) => {
        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) show(null);
      }}
      onMouseLeave={hoverClose}
      className={cn("relative z-40 w-full border-b bg-background/80 text-foreground backdrop-blur-xl", className)}
    >
      <nav aria-label="Main" className="mx-auto flex h-16 max-w-6xl items-center gap-6 px-5 sm:px-8">
        <a href={brandHref} className="flex items-center gap-2 rounded-md font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
          <span className="grid size-7 place-items-center rounded-lg bg-foreground text-background">
            <svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor" aria-hidden>
              <path d="M2 14V2l12 12V2" stroke="currentColor" strokeWidth="2.4" fill="none" strokeLinejoin="round" />
            </svg>
          </span>
          {brand}
        </a>

        <ul className="hidden items-center gap-0.5 md:flex">
          {menus.map((m) => (
            <li key={m.label} onMouseEnter={() => hoverOpen(m.label)}>
              <button
                ref={(el) => {
                  triggerRefs.current[m.label] = el;
                }}
                type="button"
                aria-expanded={open === m.label}
                aria-controls={`${uid}-panel`}
                onClick={() => show(open === m.label ? null : m.label)}
                onKeyDown={(e) => onTriggerKey(e, m.label)}
                className={cn(
                  "flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                  open === m.label ? "bg-muted text-foreground" : "text-foreground/70 hover:text-foreground",
                )}
              >
                {m.label}
                <ChevronDown className={cn("size-3.5 opacity-60 transition-transform duration-200", open === m.label && "rotate-180")} aria-hidden />
              </button>
            </li>
          ))}
          {links.map((l) => (
            <li key={l.label} onMouseEnter={hoverClose}>
              <a href={l.href ?? "#"} className="block rounded-md px-3 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                {l.label}
              </a>
            </li>
          ))}
        </ul>

        <div className="ml-auto hidden items-center gap-2 md:flex">
          <a href={signIn.href ?? "#"} className="rounded-md px-3 py-2 text-sm font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            {signIn.label}
          </a>
          <a href={cta.href ?? "#"} className="inline-flex h-9 items-center rounded-md bg-foreground px-4 text-sm font-semibold text-background transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
            {cta.label}
          </a>
        </div>

        <button
          type="button"
          aria-label={mobile ? "Close menu" : "Open menu"}
          aria-expanded={mobile}
          aria-controls={`${uid}-mobile`}
          onClick={() => setMobile((v) => !v)}
          className="ml-auto grid size-10 place-items-center rounded-md transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
        >
          <AnimatePresence mode="wait" initial={false}>
            <motion.span key={mobile ? "x" : "m"} initial={{ rotate: -90, opacity: 0 }} animate={{ rotate: 0, opacity: 1 }} exit={{ rotate: 90, opacity: 0 }} transition={{ duration: 0.15 }}>
              {mobile ? <X className="size-5" aria-hidden /> : <Menu className="size-5" aria-hidden />}
            </motion.span>
          </AnimatePresence>
        </button>
      </nav>

      {/* Desktop mega panel */}
      <AnimatePresence>
        {active && (
          <motion.div
            key="panel"
            id={`${uid}-panel`}
            initial={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
            transition={{ duration: 0.22, ease: EASE }}
            onMouseEnter={clearTimer}
            className="absolute inset-x-0 top-full hidden border-b bg-popover text-popover-foreground shadow-2xl shadow-black/5 md:block dark:shadow-black/40"
          >
            <AutoHeight>
              <AnimatePresence mode="popLayout" initial={false} custom={dir}>
                <motion.div
                  key={active.label}
                  custom={dir}
                  variants={{
                    enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 40 }),
                    center: { opacity: 1, x: 0 },
                    exit: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -40 }),
                  }}
                  initial="enter"
                  animate="center"
                  exit="exit"
                  transition={{ duration: 0.25, ease: EASE }}
                  className="mx-auto grid max-w-6xl grid-cols-[1fr_1fr_1.1fr] gap-6 px-8 py-8"
                >
                  {active.columns.map((col) => (
                    <div key={col.title}>
                      <p className="px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">{col.title}</p>
                      <ul className="mt-3 space-y-1">
                        {col.items.map((it, i) => (
                          <motion.li key={it.title} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.04 * i + 0.05, duration: 0.25 }}>
                            <a
                              href={it.href ?? "#"}
                              className="group flex gap-3 rounded-xl p-3 transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                            >
                              <span className="grid size-10 shrink-0 place-items-center rounded-lg border bg-background shadow-sm transition-colors group-hover:border-primary/40 group-hover:text-primary">
                                <it.icon className="size-[18px]" aria-hidden />
                              </span>
                              <span className="min-w-0">
                                <span className="flex items-center gap-1 text-sm font-medium">
                                  {it.title}
                                  <ArrowRight className="size-3 -translate-x-1 opacity-0 transition-all group-hover:translate-x-0 group-hover:opacity-100" aria-hidden />
                                </span>
                                <span className="mt-0.5 block text-sm leading-snug text-muted-foreground">{it.description}</span>
                              </span>
                            </a>
                          </motion.li>
                        ))}
                      </ul>
                    </div>
                  ))}
                  {active.featured && <FeaturedCard {...active.featured} />}
                </motion.div>
              </AnimatePresence>
            </AutoHeight>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Mobile menu */}
      <AnimatePresence>
        {mobile && (
          <motion.div
            id={`${uid}-mobile`}
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.3, ease: EASE }}
            className="absolute inset-x-0 top-full overflow-hidden border-b bg-background shadow-2xl shadow-black/10 md:hidden"
          >
            <div className="max-h-[70dvh] overflow-y-auto px-5 pb-6 pt-2">
              {menus.map((m) => (
                <MobileSection key={m.label} menu={m} />
              ))}
              {links.map((l) => (
                <a key={l.label} href={l.href ?? "#"} className="flex h-12 items-center border-b text-base font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  {l.label}
                </a>
              ))}
              <div className="mt-5 grid gap-2">
                <a href={cta.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-lg bg-foreground text-sm font-semibold text-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  {cta.label}
                </a>
                <a href={signIn.href ?? "#"} className="inline-flex h-11 items-center justify-center rounded-lg border text-sm font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  {signIn.label}
                </a>
              </div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </header>
  );
}

function AutoHeight({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [h, setH] = React.useState<number | "auto">("auto");
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const ro = new ResizeObserver(([e]) => setH(e.contentRect.height));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  return (
    <motion.div animate={{ height: h }} transition={{ duration: 0.25, ease: EASE }} className="overflow-hidden">
      <div ref={ref}>{children}</div>
    </motion.div>
  );
}

function FeaturedCard({ eyebrow, title, description, cta, href }: NonNullable<MegaMenu["featured"]>) {
  return (
    <a
      href={href ?? "#"}
      className="group relative flex flex-col justify-end overflow-hidden rounded-2xl border bg-linear-to-br from-primary/90 via-violet-600 to-fuchsia-600 p-6 text-white shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
    >
      <div aria-hidden className="absolute inset-0 bg-[radial-gradient(circle_at_80%_10%,rgba(255,255,255,0.35),transparent_45%)]" />
      <svg aria-hidden viewBox="0 0 200 120" className="absolute right-3 top-5 w-44 opacity-40 transition-transform duration-500 group-hover:-translate-y-1 group-hover:rotate-3">
        <rect x="20" y="20" width="120" height="80" rx="12" fill="none" stroke="white" strokeWidth="1.5" />
        <rect x="60" y="6" width="120" height="80" rx="12" fill="white" fillOpacity="0.12" stroke="white" strokeWidth="1.5" />
        <path d="M76 64 L96 48 L116 58 L150 32" stroke="white" strokeWidth="2.5" fill="none" strokeLinecap="round" />
      </svg>
      <span className="relative mt-24 w-fit rounded-full bg-white/20 px-2.5 py-0.5 text-xs font-medium backdrop-blur">{eyebrow}</span>
      <span className="relative mt-3 text-lg font-semibold leading-tight">{title}</span>
      <span className="relative mt-1 text-sm text-white/80">{description}</span>
      <span className="relative mt-4 inline-flex items-center gap-1 text-sm font-semibold">
        {cta}
        <ArrowRight className="size-4 transition-transform group-hover:translate-x-1" aria-hidden />
      </span>
    </a>
  );
}

function MobileSection({ menu }: { menu: MegaMenu }) {
  const [open, setOpen] = React.useState(false);
  const id = React.useId();
  return (
    <div className="border-b">
      <button
        type="button"
        aria-expanded={open}
        aria-controls={id}
        onClick={() => setOpen((v) => !v)}
        className="flex h-12 w-full items-center justify-between text-base font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
      >
        {menu.label}
        <ChevronDown className={cn("size-4 text-muted-foreground transition-transform duration-200", open && "rotate-180")} aria-hidden />
      </button>
      <AnimatePresence initial={false}>
        {open && (
          <motion.div
            id={id}
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: "auto", opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.25, ease: EASE }}
            className="overflow-hidden"
          >
            <div className="space-y-4 pb-4">
              {menu.columns.map((col) => (
                <div key={col.title}>
                  <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{col.title}</p>
                  <ul className="mt-1">
                    {col.items.map((it) => (
                      <li key={it.title}>
                        <a href={it.href ?? "#"} className="flex items-center gap-3 rounded-lg py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                          <span className="grid size-8 place-items-center rounded-md border bg-card">
                            <it.icon className="size-4" aria-hidden />
                          </span>
                          <span>
                            <span className="block text-sm font-medium">{it.title}</span>
                            <span className="block text-xs text-muted-foreground">{it.description}</span>
                          </span>
                        </a>
                      </li>
                    ))}
                  </ul>
                </div>
              ))}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

More in Navbars

View all →