GitHub

NavigationMenu

Horizontal navigation bar with dropdown/flyout panels for site-level navigation. Supports hover and click interactions with keyboard navigation.

Install

$npx react-principles add navigation-menu
01

Features

  • Hover & Click: Opens flyout panel on hover (desktop) or click (mobile)
  • Single Panel: Only one dropdown panel open at a time
  • Keyboard Navigation: Escape to close, Tab to navigate
  • Next.js Link Support: Compose with Next.js Link via asChild prop
  • Compound Components: List, Item, Trigger, Content, Link
02

Live Demo

03

Code Snippet

NavigationMenu.tsx
import { NavigationMenu } from "@/ui/NavigationMenu";

function MyComponent() {
  return (
    <NavigationMenu>
      <NavigationMenu.List>
        <NavigationMenu.Item>
          <NavigationMenu.Trigger>Products</NavigationMenu.Trigger>
          <NavigationMenu.Content>
            <NavigationMenu.Link href="/products/electronics">Electronics</NavigationMenu.Link>
            <NavigationMenu.Link href="/products/clothing">Clothing</NavigationMenu.Link>
            <NavigationMenu.Link href="/products/books">Books</NavigationMenu.Link>
          </NavigationMenu.Content>
        </NavigationMenu.Item>

        <NavigationMenu.Item>
          <NavigationMenu.Trigger>Solutions</NavigationMenu.Trigger>
          <NavigationMenu.Content>
            <NavigationMenu.Link href="/solutions/marketing">Marketing</NavigationMenu.Link>
            <NavigationMenu.Link href="/solutions/analytics">Analytics</NavigationMenu.Link>
          </NavigationMenu.Content>
        </NavigationMenu.Item>

        <NavigationMenu.Item>
          <NavigationMenu.Link href="/about">About</NavigationMenu.Link>
        </NavigationMenu.Item>
      </NavigationMenu.List>
    </NavigationMenu>
  );
}

// With Next.js Link (asChild pattern)
<NavigationMenu.Item>
  <NavigationMenu.Trigger asChild>
    <Link href="/products">Products</Link>
  </NavigationMenu.Trigger>
  <NavigationMenu.Content>
    <NavigationMenu.Link href="/products/electronics">Electronics</NavigationMenu.Link>
  </NavigationMenu.Content>
</NavigationMenu.Item>
04

Copy-Paste (Single File)

NavigationMenu.tsx
"use client";

import { createContext, useContext, useEffect, useState, type ReactElement, type ReactNode, cloneElement } from "react";
import { cn } from "@/shared/utils/cn";

// ─── Types ────────────────────────────────────────────────────────────────────

export interface NavigationMenuProps {
  children: ReactNode;
  className?: string;
}

export interface NavigationMenuListProps {
  children: ReactNode;
  className?: string;
}

export interface NavigationMenuItemProps {
  children: ReactNode;
  className?: string;
}

export interface NavigationMenuTriggerProps {
  children: ReactNode;
  asChild?: boolean;
  className?: string;
}

export interface NavigationMenuContentProps {
  children: ReactNode;
  className?: string;
}

export interface NavigationMenuLinkProps {
  href?: string;
  children: ReactNode;
  asChild?: boolean;
  className?: string;
}

// ─── Context ────────────────────────────────────────────────────────────────────

interface NavigationMenuContextValue {
  open: string | null;
  setOpen: (value: string | null) => void;
}

const NavigationMenuContext = createContext<NavigationMenuContextValue | null>(null);

function useNavigationMenuContext() {
  const context = useContext(NavigationMenuContext);
  if (!context) {
    throw new Error("NavigationMenu sub-components must be used inside <NavigationMenu>");
  }
  return context;
}

// ─── Main Component ───────────────────────────────────────────────────────────

export function NavigationMenu({ children, className }: NavigationMenuProps) {
  const [open, setOpen] = useState<string | null>(null);

  // Close on Escape key
  useEffect(() => {
    if (!open) return;

    const handleKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setOpen(null);
    };

    document.addEventListener("keydown", handleKey);
    return () => document.removeEventListener("keydown", handleKey);
  }, [open]);

  // Close when clicking outside
  useEffect(() => {
    const handlePointerDown = (e: MouseEvent) => {
      const target = e.target as HTMLElement;
      const menu = target.closest("[data-navigation-menu]");
      if (!menu && open) setOpen(null);
    };

    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [open]);

  // Assign indices to items
  let itemIndex = 0;
  const childrenWithProps = React.Children.map(children, (child) => {
    if (!React.isValidElement(child)) return child;

    if (child.type === NavigationMenu.Item) {
      const index = itemIndex++;
      return cloneElement(child, {
        index,
        value: `item-${index}`,
      } as any);
    }

    return child;
  });

  return (
    <NavigationMenuContext.Provider value={{ open, setOpen }}>
      <nav className={className} data-navigation-menu>
        {childrenWithProps}
      </nav>
    </NavigationMenuContext.Provider>
  );
}

// ─── List Sub-Component ───────────────────────────────────────────────────────

NavigationMenu.List = function NavigationMenuList({ children, className }: NavigationMenuListProps) {
  return (
    <ul className={cn("flex items-center gap-1", className)} role="menubar">
      {children}
    </ul>
  );
};

// ─── Item Sub-Component ───────────────────────────────────────────────────────

NavigationMenu.Item = function NavigationMenuItem({
  children,
  className,
  index,
  value,
}: NavigationMenuItemProps & { index?: number; value?: string }) {
  const { open, setOpen } = useNavigationMenuContext();
  const isOpen = open === value;
  const hasContent = React.Children.toArray(children).some(
    (child) => React.isValidElement(child) && child.type === NavigationMenu.Content
  );

  return (
    <li className={cn("relative", className)} role="none">
      {React.Children.map(children, (child) => {
        if (!React.isValidElement(child)) return child;

        if (child.type === NavigationMenu.Trigger) {
          return cloneElement(child, {
            isOpen,
            onToggle: () => setOpen(isOpen ? null : value),
            hasContent,
          } as any);
        }

        if (child.type === NavigationMenu.Content) {
          return cloneElement(child, {
            isOpen,
            onClose: () => setOpen(null),
          } as any);
        }

        return child;
      })}
    </li>
  );
};

// ─── Trigger Sub-Component ─────────────────────────────────────────────────────

NavigationMenu.Trigger = function NavigationMenuTrigger({
  asChild = false,
  children,
  className,
  isOpen,
  onToggle,
  hasContent,
}: NavigationMenuTriggerProps & {
  isOpen?: boolean;
  onToggle?: () => void;
  hasContent?: boolean;
}) {
  // Handle hover for desktop
  const handleMouseEnter = () => {
    if (hasContent && onToggle) {
      onToggle();
    }
  };

  // Handle click for touch/mobile
  const handleClick = () => {
    if (onToggle) {
      onToggle();
    }
  };

  const triggerClassName = cn(
    "inline-flex items-center justify-center rounded-sm px-4 py-2 text-sm font-medium",
    "transition-colors",
    "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40",
    "cursor-pointer",
    isOpen
      ? "bg-slate-100 dark:bg-[#1f2937] text-slate-900 dark:text-white"
      : "text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-[#1f2937]",
    className
  );

  if (asChild && React.isValidElement(children)) {
    return cloneElement(children as ReactElement<any>, {
      onMouseEnter: handleMouseEnter,
      onClick: handleClick,
      className: cn(triggerClassName, (children as ReactElement<any>).props.className),
      'aria-expanded': isOpen,
      'aria-haspopup': hasContent,
    } as any);
  }

  return (
    <button
      type="button"
      onMouseEnter={handleMouseEnter}
      onClick={handleClick}
      className={triggerClassName}
      aria-expanded={isOpen}
      aria-haspopup={hasContent}
    >
      {children}
      {/* Chevron indicator */}
      {hasContent && (
        <svg
          className={cn(
            "ml-2 h-4 w-4 transition-transform duration-200",
            isOpen ? "rotate-180" : ""
          )}
          viewBox="0 0 16 16"
          fill="none"
        >
          <path
            d="M4 6l4 4 4-4"
            stroke="currentColor"
            strokeWidth="1.5"
            strokeLinecap="round"
            strokeLinejoin="round"
          />
        </svg>
      )}
    </button>
  );
};

// ─── Content Sub-Component ───────────────────────────────────────────────────

NavigationMenu.Content = function NavigationMenuContent({
  children,
  className,
  isOpen,
  onClose,
}: NavigationMenuContentProps & {
  isOpen?: boolean;
  onClose?: () => void;
}) {
  if (!isOpen) return null;

  return (
    <div
      className={cn(
        "absolute top-full left-0 mt-2 min-w-[200px] p-2",
        "bg-white dark:bg-[#161b22]",
        "border border-slate-200 dark:border-[#1f2937]",
        "rounded-lg shadow-lg",
        "z-50",
        className
      )}
      onMouseLeave={onClose}
    >
      {children}
    </div>
  );
};

// ─── Link Sub-Component ────────────────────────────────────────────────────────

NavigationMenu.Link = function NavigationMenuLink({
  href,
  asChild = false,
  children,
  className,
}: NavigationMenuLinkProps) {
  const linkClassName = cn(
    "block rounded-sm px-4 py-2 text-sm font-medium",
    "text-slate-700 dark:text-slate-300",
    "hover:bg-slate-100 dark:hover:bg-[#1f2937] hover:text-slate-900 dark:hover:text-white",
    "transition-colors",
    "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40",
    className
  );

  if (asChild && React.isValidElement(children)) {
    return cloneElement(children as ReactElement<any>, {
      className: cn(linkClassName, (children as ReactElement<any>).props.className),
    } as any);
  }

  return (
    <a href={href} className={linkClassName}>
      {children}
    </a>
  );
};
05

Usage Examples

Basic dropdown menu

<NavigationMenu>
  <NavigationMenu.List>
    <NavigationMenu.Item>
      <NavigationMenu.Trigger>Products</NavigationMenu.Trigger>
      <NavigationMenu.Content>
        <NavigationMenu.Link href="/products/electronics">Electronics</NavigationMenu.Link>
        <NavigationMenu.Link href="/products/clothing">Clothing</NavigationMenu.Link>
      </NavigationMenu.Content>
    </NavigationMenu.Item>
  </NavigationMenu.List>
</NavigationMenu>

With Next.js Link (asChild pattern)

<NavigationMenu>
  <NavigationMenu.List>
    <NavigationMenu.Item>
      <NavigationMenu.Trigger asChild>
        <Link href="/products">Products</Link>
      </NavigationMenu.Trigger>
      <NavigationMenu.Content>
        <NavigationMenu.Link href="/products/electronics">Electronics</NavigationMenu.Link>
      </NavigationMenu.Content>
    </NavigationMenu.Item>
  </NavigationMenu.List>
</NavigationMenu>

Single level navigation (no dropdowns)

<NavigationMenu>
  <NavigationMenu.List>
    <NavigationMenu.Item>
      <NavigationMenu.Link href="/">Home</NavigationMenu.Link>
    </NavigationMenu.Item>
    <NavigationMenu.Item>
      <NavigationMenu.Link href="/about">About</NavigationMenu.Link>
    </NavigationMenu.Item>
    <NavigationMenu.Item>
      <NavigationMenu.Link href="/contact">Contact</NavigationMenu.Link>
    </NavigationMenu.Item>
  </NavigationMenu.List>
</NavigationMenu>
06

Props

ComponentPropTypeDefaultDescription
NavigationMenu.TriggerasChildbooleanfalseMerge props with child element (e.g., Next.js Link)
classNamestringAdditional CSS classes
NavigationMenu.LinkhrefstringLink href attribute
asChildbooleanfalseMerge props with child element (e.g., Next.js Link)
React Principles