Navigation menu
A horizontal menu with rich flyout panels, for marketing-style top navigation.
- Status
- Experimental
- Level
- Organism
- Category
- Navigation
- Adoption
- Not used yet
import { NavigationMenu } from "@oration/canon/components/navigation-menu";packages/canon/src/components/navigation-menu.tsximport { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle,} from "@oration/canon/components/navigation-menu";import { toast } from "@oration/canon/components/toast";import { Building2Icon, CalendarClockIcon, FileTextIcon, ListChecksIcon,} from "lucide-react";export function Hero() { const product = [ { title: "Invoice capture", description: "Read PDFs and emails into coded invoices.", icon: FileTextIcon, }, { title: "Approvals", description: "Route by amount, entity and cost center.", icon: ListChecksIcon, }, { title: "Payment runs", description: "Schedule ACH, wire and card batches.", icon: CalendarClockIcon, }, { title: "Supplier portal", description: "Suppliers keep W-9s and bank details current.", icon: Building2Icon, }, ]; const solutions = [ { title: "For AP teams", description: "Clear the inbox and close on time.", }, { title: "For controllers", description: "Audit trails, accruals and a clean close.", }, { title: "For procurement", description: "Match every invoice to its purchase order.", }, ]; return ( <div className="flex w-full max-w-2xl flex-wrap items-center gap-x-4 gap-y-2 rounded-xl bg-background px-3 py-2 shadow-border"> <span className="flex items-center gap-2 text-13 font-semibold"> <MonogramTile name="Cedarline" color="teal" size="md" /> Cedarline </span> <NavigationMenu aria-label="Cedarline"> <NavigationMenuList> <NavigationMenuItem> <NavigationMenuTrigger>Product</NavigationMenuTrigger> <NavigationMenuContent> <ul className="grid w-[27rem] grid-cols-2 gap-1 max-sm:w-72 max-sm:grid-cols-1"> {product.map((item) => ( <li key={item.title}> <NavigationMenuLink href={`#${item.title.toLowerCase().replaceAll(" ", "-")}`} closeOnClick onClick={() => toast.add({ title: `Opening ${item.title}`, }) } className="items-start" > <item.icon aria-hidden="true" className="mt-0.5 text-muted-foreground" /> <span className="flex flex-col gap-0.5"> <span className="text-13 font-medium"> {item.title} </span> <span className="text-xs text-muted-foreground"> {item.description} </span> </span> </NavigationMenuLink> </li> ))} </ul> </NavigationMenuContent> </NavigationMenuItem> <NavigationMenuItem> <NavigationMenuTrigger>Solutions</NavigationMenuTrigger> <NavigationMenuContent> <ul className="flex w-64 flex-col gap-1"> {solutions.map((item) => ( <li key={item.title}> <NavigationMenuLink href={`#${item.title.toLowerCase().replaceAll(" ", "-")}`} closeOnClick onClick={() => toast.add({ title: `Opening ${item.title}`, }) } className="flex-col items-start gap-0.5" > <span className="text-13 font-medium"> {item.title} </span> <span className="text-xs text-muted-foreground"> {item.description} </span> </NavigationMenuLink> </li> ))} </ul> </NavigationMenuContent> </NavigationMenuItem> <NavigationMenuItem> <NavigationMenuLink href="#pricing" onClick={() => toast.add({ title: "Opening Pricing" }) } className={navigationMenuTriggerStyle()} > Pricing </NavigationMenuLink> </NavigationMenuItem> </NavigationMenuList> </NavigationMenu> <Button type="button" className="ml-auto" onClick={() => toast.add({ title: "Demo requested", description: "Maya Okafor will reply within one business day.", }) } > Book a demo </Button> </div> );}Usage#
Navigation menu is a horizontal row of top-level links whose triggers open rich flyout panels, built on Base UI Navigation Menu. It's for site-level navigation on public, marketing-style pages, where a section needs a few described links. Oration's workspace doesn't use it: the app navigates with the Sidebar, and actions live in menus and toolbars. The mistake to avoid is treating it as a menu of commands; every item in it goes somewhere.
When to use
- For the top bar of a public page such as a product site, a changelog or a help center.
- When a top-level section needs a panel of three to eight links, each with a one-line description.
- To mix flyout triggers with plain links (Pricing, Customers) in one row.
When not to use
- For navigation inside the workspace. It is always the rail. Use Sidebar
- For a list of actions on the current page or record. Use Dropdown menu
- For an editor's File, Edit and View commands. Use Menubar
- For switching views of the same content. Use Tabs
- For jumping to any page by name. Use Command menu
Links, not commands
The One Filled Button Rule
Anatomy#
- Trigger.
NavigationMenuTrigger: a 36px ghost control at 14px medium with a 12px chevron. Open, it keeps a Well Gray fill at 50%. - Chevron. Rotates 180° while its panel is open.
- Plain link. A
NavigationMenuLinkstyled withnavigationMenuTriggerStyle(), for sections without a panel. - Popup. One shared popup and viewport, 10px corners on Popover White, 8px below the row. It resizes to each panel.
- Panel link.
NavigationMenuLinkinsideNavigationMenuContent: 8px padding, Well Gray on hover and focus, an icon, a title and a description.
Examples#
Plain links and the current page
Style a link without a panel with navigationMenuTriggerStyle() so it sits in the row like a trigger. Mark the current page with active and aria-current.
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger, navigationMenuTriggerStyle,} from "@oration/canon/components/navigation-menu";import { toast } from "@oration/canon/components/toast";export function PlainLinks() { const current = "Customers"; const links = ["Pricing", "Customers", "Security"]; return ( <NavigationMenu aria-label="Cedarline"> <NavigationMenuList> <NavigationMenuItem> <NavigationMenuTrigger>Product</NavigationMenuTrigger> <NavigationMenuContent> <ul className="flex w-56 flex-col gap-1"> {[ "Invoice capture", "Approvals", "Payment runs", ].map((title) => ( <li key={title}> <NavigationMenuLink href={`#${title.toLowerCase().replaceAll(" ", "-")}`} closeOnClick onClick={() => toast.add({ title: `Opening ${title}`, }) } className="text-[13px]" > {title} </NavigationMenuLink> </li> ))} </ul> </NavigationMenuContent> </NavigationMenuItem> {links.map((link) => ( <NavigationMenuItem key={link}> <NavigationMenuLink href={`#${link.toLowerCase()}`} active={link === current} aria-current={link === current ? "page" : undefined} onClick={() => toast.add({ title: `Opening ${link}` }) } className={navigationMenuTriggerStyle()} > {link} </NavigationMenuLink> </NavigationMenuItem> ))} </NavigationMenuList> </NavigationMenu> );}Aligned to the end
A menu at the right of a bar opens its panel with align="end" so the popup stays inside the page.
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger,} from "@oration/canon/components/navigation-menu";import { toast } from "@oration/canon/components/toast";import { BookOpenIcon, WebhookIcon } from "lucide-react";export function AlignEnd() { const resources = [ { title: "Help center", description: "Guides for AP clerks and approvers.", icon: BookOpenIcon, }, { title: "API reference", description: "Invoices, suppliers and payment runs.", icon: WebhookIcon, }, ]; return ( <div className="flex w-full max-w-xl items-center justify-between rounded-xl bg-background px-3 py-2 shadow-border"> <span className="text-13 font-semibold">Cedarline</span> <NavigationMenu aria-label="Resources" align="end"> <NavigationMenuList> <NavigationMenuItem> <NavigationMenuTrigger>Resources</NavigationMenuTrigger> <NavigationMenuContent> <ul className="flex w-72 flex-col gap-1"> {resources.map((item) => ( <li key={item.title}> <NavigationMenuLink href={`#${item.title.toLowerCase().replaceAll(" ", "-")}`} closeOnClick onClick={() => toast.add({ title: `Opening ${item.title}`, }) } className="items-start" > <item.icon aria-hidden="true" className="mt-0.5 text-muted-foreground" /> <span className="flex flex-col gap-0.5"> <span className="text-13 font-medium"> {item.title} </span> <span className="text-xs text-muted-foreground"> {item.description} </span> </span> </NavigationMenuLink> </li> ))} </ul> </NavigationMenuContent> </NavigationMenuItem> </NavigationMenuList> </NavigationMenu> </div> );}States#
import { navigationMenuTriggerStyle } from "@oration/canon/components/navigation-menu";import { cn } from "@oration/canon/lib/utils";import { ChevronDownIcon } from "lucide-react";export function StatesMatrix() { return ( <div className="flex flex-wrap items-end justify-center gap-6"> {triggerStates.map((state) => ( <div key={state.label} className="flex flex-col items-center gap-2" > <span className={cn( navigationMenuTriggerStyle(), "pointer-events-none", state.className, )} > Product <ChevronDownIcon aria-hidden="true" className={cn( "relative top-px ml-1 size-3", state.open && "rotate-180", )} /> </span> <span className="text-xs text-muted-foreground"> {state.label} </span> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Transparent trigger, ink label. |
| Hover | Well Gray fill over 150ms. Hovering opens the panel after 50ms. |
| Focus visible | A 3px Focus Indigo ring at 50% and a 1px outline. |
| Open | data-popup-open: Well Gray at 50%, chevron turned, panel shown. Moving to another trigger swaps panels inside the same popup. |
| Active link | active sets data-active: Well Gray at 50%. Pair it with aria-current="page". |
| Disabled | 50% opacity and no pointer events. |
Behavior#
- Hover or focus a trigger and press Enter, Space or Down to open its panel. The popup opens after
delay(50ms) and closescloseDelay(50ms) after the pointer leaves. - All panels render into one shared popup and viewport. Moving between triggers keeps the popup open and animates its size and position over 350ms.
- The popup enters from a 0.9 scale and fades; it exits over 150ms.
- Links don't close the popup by default. Pass
closeOnClickfor links that stay on the same page or route client-side. alignonNavigationMenualigns the popup to the start, center or end of the trigger.value,defaultValueandonValueChangecontrol which item is open;nullcloses it.- Escape closes the popup and returns focus to its trigger.
Do and don't#
Content#
- Triggers are one-word section names in sentence case: Product, Solutions, Resources.
- Panel link titles name the page; descriptions are one short sentence with no trailing call to action.
- Don't repeat the section name in each link: under Product, write Approvals, not Product approvals.
Accessibility#
- The root renders a
<nav>. Give it anaria-labelwhen the page has more than one navigation landmark. - Triggers expose
aria-expandedand control their panel. Panel links are plain anchors, so they announce and open like links. - Mark the current page with
activeandaria-current="page"on its link. - Hover opening is a convenience; everything works from the keyboard and on touch by tapping the trigger.
- Transitions aren't reduced for reduced-motion users; see known gaps.
| Keys | Action |
|---|---|
| Tab | Moves into the row, then into an open panel's links. |
| Enter | Opens the focused trigger's panel, or follows a link. |
| Space | Opens the focused trigger's panel. |
| ↓ | Opens the panel and moves focus into it. |
| ←→ | Moves between triggers and links in the row (roving focus). |
| Esc | Closes the panel and focuses its trigger. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Trigger and link hover, open and active fills |
--popover | Popup background |
--popover-foreground | Popup text |
--ring | Focus ring at 50% |
ring-foreground/10 | The popup's 1px ring |
shadow | The popup's drop shadow |
--radius-lg | 10px trigger and popup corners |
API reference#
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Nothing in apps/web imports it. The workspace has no top navigation, so treat it as experimental.
Motion runs outside the system: panels and the popup move over 350ms on cubic-bezier(0.22, 1, 0.36, 1) and enter from a 0.9 scale, where DESIGN.md caps state changes at 320ms, uses the house ease and enters popovers from 0.97. Nothing is reduced for reduced-motion users.
The popup draws its edge with shadow and ring-1 instead of the overlay shadow and ring menus and popovers use.
Triggers are 36px, not the 32px control height, and focus draws both a ring and an outline.
NavigationMenuContent carries Radix-era classes (data-[motion=…], group-data-[viewport=false]) that Base UI never sets, so those branches do nothing.
NavigationMenuIndicator wraps Base UI's NavigationMenu.Icon (the trigger icon slot) and styles itself with Radix data-[state] attributes, so it isn't a working indicator. Base UI's NavigationMenu.Arrow isn't wrapped.
NavigationMenuPositioner is exported, but NavigationMenu always renders its own, so side and sideOffset can't be changed from the root.