Responsive Navigation
A responsive navigation bar that automatically moves items to an overflow menu based on available space.
A flexible navigation system composed of a provider, a desktop container, and a mobile menu. The menu toggler can be placed anywhere within the provider.
"use client"
import { DesktopNav, MobileNav, ResponsiveMenuProvider, type NavItem,} from "@/components/ui/responsive-nav"
const navItems: NavItem[] = [ { label: "Home", variant: "link", href: "#" }, { label: "About", variant: "link", href: "#" }, { label: "Contact", variant: "link", href: "#" },]
export default function ResponsiveMenuMinimalistExample() { return ( <div className="flex h-full w-full items-start justify-center p-6"> <header className="flex w-full items-center justify-between gap-4 p-2 rounded-lg bg-card px-4 shadow"> <div className="font-bold text-lg">SimpleLogo</div>
<ResponsiveMenuProvider items={navItems}> <div className="flex items-center justify-end gap-4"> <DesktopNav /> <MobileNav /> </div> </ResponsiveMenuProvider> </header> </div> )}
Installation
Section titled “Installation”This component relies on other items which must be installed first.
Copy and paste the following code into your project.
components/ui/responsive-nav.tsx
"use client"
import * as React from "react"import { cn } from "@/lib/utils"import { Button, buttonVariants } from "@/components/ui/button"import { ChevronDownIcon, MenuIcon } from "lucide-react"import { Sheet, SheetContent, SheetTrigger, SheetClose, SheetHeader,} from "@/components/ui/sheet"import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle,} from "@/components/ui/navigation-menu"import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
type ActionItem = { variant: "action"; action: () => void }type LinkItem = { variant: "link"; href: string }type ChildrenItem = { variant: "children"; children: NavItem[] }type NavItemVariants = ActionItem | LinkItem | ChildrenItemexport type NavItem = { label: string icon?: React.ReactNode} & NavItemVariants
interface ResponsiveMenuContextType { visibleItems: NavItem[] hiddenItems: NavItem[] setVisibleItems: React.Dispatch<React.SetStateAction<NavItem[]>> setHiddenItems: React.Dispatch<React.SetStateAction<NavItem[]>> items: NavItem[]}
const ResponsiveMenuContext = React.createContext<ResponsiveMenuContextType | undefined>(undefined)
function useResponsiveMenu() { const context = React.useContext(ResponsiveMenuContext) if (!context) { throw new Error("useResponsiveMenu must be used within a ResponsiveMenuProvider") } return context}
export function ResponsiveMenuProvider({ items, children }: { items: NavItem[]; children: React.ReactNode }) { const [visibleItems, setVisibleItems] = React.useState<NavItem[]>(items); const [hiddenItems, setHiddenItems] = React.useState<NavItem[]>([]);
return ( <ResponsiveMenuContext.Provider value={{ visibleItems, hiddenItems, setVisibleItems, setHiddenItems, items }}> <div className="w-full">{children}</div> </ResponsiveMenuContext.Provider> );}
export function DesktopNav() { const { visibleItems, items, setVisibleItems, setHiddenItems } = useResponsiveMenu()
const containerRef = React.useRef<HTMLDivElement>(null); const itemsRef = React.useRef<(HTMLLIElement | null)[]>([]);
const MORE_BUTTON_WIDTH = 100;
React.useLayoutEffect(() => { if (!containerRef.current) return;
const observer = new ResizeObserver(([entry]) => { const containerWidth = entry.contentRect.width; let accumulatedWidth = 100; const newVisible: NavItem[] = []; const newHidden: NavItem[] = [];
const itemWidths = itemsRef.current.map(ref => ref?.offsetWidth ?? 0); const totalWidth = itemWidths.reduce((sum, width) => sum + width, 0);
const needsMoreButton = totalWidth > containerWidth; const availableWidth = needsMoreButton ? containerWidth - MORE_BUTTON_WIDTH : containerWidth;
for (let i = 0; i < items.length; i++) { const itemWidth = itemWidths[i]; if (accumulatedWidth + itemWidth <= availableWidth) { newVisible.push(items[i]); accumulatedWidth += itemWidth; } else { newHidden.push(...items.slice(i)); break; } }
setVisibleItems(newVisible); setHiddenItems(newHidden); });
observer.observe(containerRef.current); return () => observer.disconnect(); }, [items]);
return ( <div className="w-full" ref={containerRef}> {/* This menu is ONLY for measurement. It is not visible. */} <NavigationMenu className="absolute invisible h-0 w-full overflow-hidden"> <NavigationMenuList> {items.map((item, i) => ( <NavigationMenuItem key={item.label} ref={el => { itemsRef.current[i] = el; }}> <span className={cn(navigationMenuTriggerStyle(), "flex-shrink-0")}> {item.label} </span> </NavigationMenuItem> ))} </NavigationMenuList> </NavigationMenu>
<NavigationMenu> <NavigationMenuList> {visibleItems.map((item) => ( <RenderItem item={item} isMobile={false} key={item.label} /> ))} </NavigationMenuList> </NavigationMenu> </div> )}
export function MobileNav() { const { hiddenItems } = useResponsiveMenu() if (hiddenItems.length === 0) return null
return ( <Sheet> <SheetTrigger asChild> <Button variant="ghost"> <MenuIcon className="size-4" /> <span className="sr-only">More</span> </Button> </SheetTrigger> <SheetContent> <SheetHeader>Menu</SheetHeader> <div className="p-4 flex flex-col gap-2 items-stretch"> {hiddenItems.map((item) => ( <RenderItem item={item} isMobile={true} key={item.label} /> ))} </div> </SheetContent> </Sheet> )}
function RenderItem({ item, isMobile }: { item: NavItem; isMobile: boolean }) { const renderContent = () => { switch (item.variant) { case "action": return ( <Button variant="ghost" onClick={item.action}> {item.icon} {item.label} </Button> ) case "link": return ( <Button variant="ghost" asChild className={cn("flex items-center gap-2")}> <a href={item.href}>{item.icon} {item.label}</a> </Button> ) case "children": if (isMobile) { return ( <Collapsible key={item.label}> <CollapsibleTrigger className="group flex items-center justify-center w-full rounded-md p-2" asChild> <Button variant="ghost" > {item.label} <ChevronDownIcon size={20} className="ltr:-rotate-90 rtl:rotate-90 group-data-[state=open]:rotate-0 transition-transform duration-200" /> </Button> </CollapsibleTrigger> <CollapsibleContent className="p-4"> {item.children.map((subItem) => ( <RenderItem item={subItem} isMobile={true} key={subItem.label} /> ))} </CollapsibleContent> </Collapsible> ) } return ( <NavigationMenuItem key={item.label}> <NavigationMenuTrigger className={`${buttonVariants({ variant: "ghost" })}`}>{item.label}</NavigationMenuTrigger> <NavigationMenuContent> <ul className="grid gap-3 p-4 "> {item.children.map((subItem) => ( <li key={subItem.label}> <RenderItem item={subItem} isMobile={false} /> </li> ))} </ul> </NavigationMenuContent> </NavigationMenuItem> ) default: return null } }
if (isMobile) { if (item.variant === "link" || item.variant === "action") { return <SheetClose asChild>{renderContent()}</SheetClose> } return renderContent() }
if (item.variant === "link") { return <NavigationMenuItem>{renderContent()}</NavigationMenuItem> } return renderContent()}
Update the import paths to match your project setup.
import { DesktopNav, MobileNav, ResponsiveMenuProvider, type NavItem,} from "@/components/ui/responsive-nav"
const navItems: NavItem[] = [ { label: "Home", variant: "link", href: "#" }, { label: "About", variant: "link", href: "#" }, { label: "Contact", variant: "link", href: "#" },]
<div className="flex h-full items-start justify-center p-6"> <header className="flex w-full items-center justify-between gap-4 p-2 rounded-lg bg-card px-4 shadow"> <div className="font-bold text-lg">SimpleLogo</div> <ResponsiveMenuProvider items={navItems}> <div className="flex items-center justify-end gap-4"> <DesktopNav /> <MobileNav /> </div> </ResponsiveMenuProvider> </header></div>
Examples
Section titled “Examples”"use client"
import { DesktopNav, MobileNav, ResponsiveMenuProvider, type NavItem,} from "@/components/ui/responsive-nav"
const navItems: NavItem[] = [ { label: "Home", variant: "link", href: "#" }, { label: "About", variant: "link", href: "#" }, { label: "Contact", variant: "link", href: "#" },]
export default function ResponsiveMenuMinimalistExample() { return ( <div className="flex h-full w-full items-start justify-center p-6"> <header className="flex w-full items-center justify-between gap-4 p-2 rounded-lg bg-card px-4 shadow"> <div className="font-bold text-lg">SimpleLogo</div>
<ResponsiveMenuProvider items={navItems}> <div className="flex items-center justify-end gap-4"> <DesktopNav /> <MobileNav /> </div> </ResponsiveMenuProvider> </header> </div> )}
Action Buttons
Section titled “Action Buttons”"use client"
import { DesktopNav, MobileNav, ResponsiveMenuProvider, type NavItem,} from "@/components/ui/responsive-nav"import { BellIcon, LogOutIcon, UserIcon } from "lucide-react"
const navItems: NavItem[] = [ { label: "Home", variant: "link", href: "#" }, { label: "Dashboard", variant: "link", href: "#" }, { label: "Projects", variant: "link", href: "#" }, { label: "Notifications", variant: "action", action: () => alert("Notifications clicked!"), icon: <BellIcon className="size-4" /> }, { label: "Profile", variant: "action", action: () => alert("Profile clicked!"), icon: <UserIcon className="size-4" /> }, { label: "Logout", variant: "action", action: () => alert("Logout clicked!"), icon: <LogOutIcon className="size-4" /> },]
export default function ResponsiveMenuActionsExample() { return ( <ResponsiveMenuProvider items={navItems}> <div className="flex h-full w-full items-start justify-center p-6"> <header className="flex w-full justify-between items-center gap-4 rounded-lg bg-card p-4 px-4 shadow"> <div className="flex-shrink-0 font-bold text-lg">WebApp</div> <DesktopNav /> <div className="hidden flex-shrink-0 items-center gap-2 font-bold text-lg sm:flex"> <p>Actions</p> <MobileNav /> </div> </header> </div> </ResponsiveMenuProvider> )}
Complex Nested Items
Section titled “Complex Nested Items”"use client"
import { DesktopNav, MobileNav, ResponsiveMenuProvider, type NavItem,} from "@/components/ui/responsive-nav"
const navItems: NavItem[] = [ { label: "Home", variant: "link", href: "#" }, { label: "Services", variant: "children", children: [ { label: "Web Development", variant: "link", href: "#web-dev" }, { label: "Mobile Development", variant: "link", href: "#mobile-dev" }, ], }, { label: "Products", variant: "children", children: [ { label: "Product A", variant: "link", href: "#prod-a" }, { label: "Product B", variant: "link", href: "#prod-b" }, { label: "Product C", variant: "link", href: "#prod-c" }, ], }, { label: "Blog", variant: "link", href: "#" }, { label: "About Us", variant: "link", href: "#" }, { label: "Contact", variant: "link", href: "#" },]
export default function ResponsiveMenuNestedExample() { return ( <ResponsiveMenuProvider items={navItems}> <div className="flex h-full items-start justify-center p-6"> <header className="flex w-full items-center justify-between gap-4 p-2 rounded-lg bg-card px-4 shadow"> <div className="font-bold text-lg">MegaCorp</div>
<div className="flex w-full items-center justify-end gap-4"> <DesktopNav /> <MobileNav /> </div> </header> </div> </ResponsiveMenuProvider> )}