A site navigation bar with flyout panels sharing one animated viewport that resizes to its content, with keyboard and pointer intent handling, built on Radix.
import { BarChart3, Code2, Layers, Rocket, Users } from "lucide-react";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ballmac/navigation-menu";
const products = [
{ icon: Layers, title: "Workspaces", text: "Organize projects and files." },
{ icon: BarChart3, title: "Analytics", text: "Track usage without setup." },
{ icon: Code2, title: "API", text: "Build on the same data." },
{ icon: Users, title: "Teams", text: "Roles, groups and audit logs." },
];
export default function NavigationMenuDemo() {
return (
<div className="flex min-h-64 w-full max-w-lg justify-center">
<NavigationMenu aria-label="Main">
<NavigationMenuList>
<NavigationMenuItem value="products">
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[min(28rem,calc(100vw-4rem))] gap-1 sm:grid-cols-2">
{products.map(({ icon: Icon, title, text }) => (
<li key={title}>
<NavigationMenuLink href={`#${title.toLowerCase()}`}>
<span className="flex items-center gap-2 font-medium">
<Icon aria-hidden="true" /> {title}
</span>
<span className="text-xs text-muted-foreground">{text}</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Resources</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-64 gap-1">
<li>
<NavigationMenuLink href="#docs">
<span className="flex items-center gap-2 font-medium">
<Rocket aria-hidden="true" /> Quickstart
</span>
<span className="text-xs text-muted-foreground">Ship your first project in minutes.</span>
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#pricing" topLevel>
Pricing
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/navigation-menuInstall the dependencies.
$ pnpm add radix-ui lucide-react class-variance-authorityAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/navigation-menu.tsx// Ballmac UI: Navigation Menu. https://ui.ballmac.com/components/navigation-menu // Based on shadcn/ui Navigation Menu (MIT, Copyright (c) 2023 shadcn), restyled with a shared animated viewport that sizes to its content, and exported trigger styles for plain links. "use client"; import * as React from "react"; import { cva } from "class-variance-authority"; import { ChevronDown } from "lucide-react"; import { NavigationMenu as Primitive } from "radix-ui"; import { useDirection } from "@/lib/ballmac/direction"; import { cn } from "@/lib/utils"; type NavigationMenuProps = React.ComponentProps<typeof Primitive.Root> & { /** Render panels in a shared viewport that resizes between items. Turn off to position each panel under its trigger. */ viewport?: boolean; /** Where the shared viewport sits under the menu: from its left edge, or centered under the whole menu (good for wide mega menus). */ viewportAlign?: "start" | "center"; }; function NavigationMenu({ className, children, viewport = true, viewportAlign = "start", ...props }: NavigationMenuProps) { const dir = useDirection(props.dir); return ( <Primitive.Root dir={dir} data-slot="navigation-menu" data-viewport={viewport} className={cn( "group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", className, )} {...props} > {children} {viewport && <NavigationMenuViewport align={viewportAlign} />} </Primitive.Root> ); } type NavigationMenuListProps = React.ComponentProps<typeof Primitive.List>; function NavigationMenuList({ className, ...props }: NavigationMenuListProps) { return ( <Primitive.List data-slot="navigation-menu-list" className={cn("group flex flex-1 list-none items-center justify-center gap-1", className)} {...props} /> ); } type NavigationMenuItemProps = React.ComponentProps<typeof Primitive.Item>; function NavigationMenuItem({ className, ...props }: NavigationMenuItemProps) { return ( <Primitive.Item data-slot="navigation-menu-item" className={cn("relative", className)} {...props} /> ); } /** Class names for a top-level trigger or link, so plain links can match triggers. */ const navigationMenuTriggerStyle = cva( "group/trigger inline-flex h-9 w-max items-center justify-center gap-1 rounded-md bg-transparent px-3.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-accent/60 data-[state=open]:bg-accent/60 data-[state=open]:text-accent-foreground", ); type NavigationMenuTriggerProps = React.ComponentProps<typeof Primitive.Trigger>; function NavigationMenuTrigger({ className, children, ...props }: NavigationMenuTriggerProps) { return ( <Primitive.Trigger data-slot="navigation-menu-trigger" className={cn(navigationMenuTriggerStyle(), className)} {...props} > {children} <ChevronDown aria-hidden="true" className="relative top-px size-3.5 transition-transform duration-200 group-data-[state=open]/trigger:rotate-180 motion-reduce:transition-none" /> </Primitive.Trigger> ); } type NavigationMenuContentProps = React.ComponentProps<typeof Primitive.Content>; function NavigationMenuContent({ className, ...props }: NavigationMenuContentProps) { return ( <Primitive.Content data-slot="navigation-menu-content" className={cn( "top-0 start-0 w-full p-2 pe-2.5 data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-32 data-[motion=from-start]:slide-in-from-left-32 data-[motion=to-end]:slide-out-to-right-32 data-[motion=to-start]:slide-out-to-left-32 motion-reduce:animate-none md:absolute md:w-auto", "group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-xl group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow-lg", className, )} {...props} /> ); } type NavigationMenuViewportProps = React.ComponentProps<typeof Primitive.Viewport> & { /** Align the viewport to the menu's left edge or center it under the menu. */ align?: "start" | "center"; }; function NavigationMenuViewport({ className, align = "start", ...props }: NavigationMenuViewportProps) { return ( <div className={cn("absolute top-full start-0 isolate z-50 flex justify-center", align === "center" && "w-full")}> <Primitive.Viewport data-slot="navigation-menu-viewport" className={cn( "origin-top-center relative mt-1.5 shrink-0 h-(--radix-navigation-menu-viewport-height) w-full overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-[0_12px_36px_-10px_rgb(0_0_0/0.25)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-(--radix-navigation-menu-viewport-width) motion-reduce:animate-none", className, )} {...props} /> </div> ); } type NavigationMenuLinkProps = React.ComponentProps<typeof Primitive.Link> & { /** Style the link like a top-level trigger (for links placed directly in the bar, such as Pricing). Server components can use this instead of calling `navigationMenuTriggerStyle`. */ topLevel?: boolean; }; function NavigationMenuLink({ className, topLevel = false, ...props }: NavigationMenuLinkProps) { return ( <Primitive.Link data-slot="navigation-menu-link" className={cn( topLevel ? navigationMenuTriggerStyle() : "flex flex-col gap-1 rounded-lg p-2.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active=true]:bg-accent/60 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", className, )} {...props} /> ); } type NavigationMenuIndicatorProps = React.ComponentProps<typeof Primitive.Indicator>; function NavigationMenuIndicator({ className, ...props }: NavigationMenuIndicatorProps) { return ( <Primitive.Indicator data-slot="navigation-menu-indicator" className={cn( "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in motion-reduce:animate-none", className, )} {...props} > <div className="relative top-[60%] size-2 rotate-45 rounded-ss-sm border-t border-s bg-popover shadow-md" /> </Primitive.Indicator> ); } export { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuContent, NavigationMenuTrigger, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, navigationMenuTriggerStyle, type NavigationMenuProps, type NavigationMenuListProps, type NavigationMenuItemProps, type NavigationMenuContentProps, type NavigationMenuTriggerProps, type NavigationMenuLinkProps, type NavigationMenuIndicatorProps, type NavigationMenuViewportProps, };Update the import paths to match your project setup.
Usage
import { NavigationMenu, NavigationMenuList, NavigationMenuItem, NavigationMenuContent, NavigationMenuTrigger, NavigationMenuLink, NavigationMenuIndicator, NavigationMenuViewport, navigationMenuTriggerStyle } from "@/components/ballmac/navigation-menu"The full example is in the Code tab above.
Examples
Without viewport
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@/components/ballmac/navigation-menu";
export default function NavigationMenuStates() {
return (
<div className="flex min-h-56 w-full max-w-md justify-center">
<NavigationMenu viewport={false} aria-label="Docs">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>Guides</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-56 gap-1">
{["Installation", "Theming", "Accessibility"].map((t) => (
<li key={t}>
<NavigationMenuLink href={`#${t}`} active={t === "Theming"}>
{t}
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#changelog" topLevel>
Changelog
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
);
}API reference
<NavigationMenu>
| Prop | Type | Default |
|---|---|---|
viewportRender panels in a shared viewport that resizes between items. Turn off to position each panel under its trigger. | boolean | true |
viewportAlignWhere the shared viewport sits under the menu: from its left edge, or centered under the whole menu (good for wide mega menus). | "start" | "center" | "start" |
<NavigationMenuViewport>
| Prop | Type | Default |
|---|---|---|
alignAlign the viewport to the menu's left edge or center it under the menu. | "start" | "center" | "start" |
<NavigationMenuLink>
| Prop | Type | Default |
|---|---|---|
topLevelStyle the link like a top-level trigger (for links placed directly in the bar, such as Pricing). Server components can use this instead of calling `navigationMenuTriggerStyle`. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space / ArrowDown | Opens the focused panel |
| Tab | Moves into the panel links |
| ArrowLeft / ArrowRight | Moves between top-level items |
| Escape | Closes the panel |
Use with AI
Top-level triggers open content panels of links. Use navigationMenuTriggerStyle for plain top-level links. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Navigation Menu (@ballmac/navigation-menu) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Marketing site headers
- Docs navigation with grouped links
Not for
- An application command menu; use menubar
- A small overflow list; use dropdown-menu
Registry JSON: https://ui.ballmac.com/r/navigation-menu.json
Credits
Based on shadcn/ui Navigation Menu, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Separator
A semantic or decorative divider with horizontal, vertical, and centered-label layouts.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.
Back To Top
A floating button that appears after scrolling, draws a progress ring, scrolls smoothly (instantly under reduced motion) and moves focus to the content.