Skip to content

Navigation menu

A horizontal menu with rich flyout panels, for marketing-style top navigation.

Category
Navigation
Adoption
Not used yet
import { NavigationMenu } from "@oration/canon/components/navigation-menu";
packages/canon/src/components/navigation-menu.tsx
Cedarline
import { 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

Every item in a navigation menu is a link to a page. If an item runs an action, it belongs in a dropdown menu or a toolbar.

The One Filled Button Rule

A top bar may carry one filled call to action beside the menu, such as Book a demo. The triggers themselves stay ghost.

Anatomy#

ProductSolutionsPricing
Invoice captureRead PDFs and emails into coded invoices.ApprovalsRoute by amount, entity and cost center.
  1. Trigger. NavigationMenuTrigger: a 36px ghost control at 14px medium with a 12px chevron. Open, it keeps a Well Gray fill at 50%.
  2. Chevron. Rotates 180° while its panel is open.
  3. Plain link. A NavigationMenuLink styled with navigationMenuTriggerStyle(), for sections without a panel.
  4. Popup. One shared popup and viewport, 10px corners on Popover White, 8px below the row. It resizes to each panel.
  5. Panel link. NavigationMenuLink inside NavigationMenuContent: 8px padding, Well Gray on hover and focus, an icon, a title and a description.

Examples#

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.

Cedarline
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#

ProductRest
ProductHover
ProductFocus
ProductOpen
ProductDisabled
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>    );}
States
StateTreatment
RestTransparent trigger, ink label.
HoverWell Gray fill over 150ms. Hovering opens the panel after 50ms.
Focus visibleA 3px Focus Indigo ring at 50% and a 1px outline.
Opendata-popup-open: Well Gray at 50%, chevron turned, panel shown. Moving to another trigger swaps panels inside the same popup.
Active linkactive sets data-active: Well Gray at 50%. Pair it with aria-current="page".
Disabled50% 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 closes closeDelay (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 closeOnClick for links that stay on the same page or route client-side.
  • align on NavigationMenu aligns the popup to the start, center or end of the trigger. value, defaultValue and onValueChange control which item is open; null closes it.
  • Escape closes the popup and returns focus to its trigger.

Do and don't#

ProductSolutionsPricing
Do. Use it for a public site's sections: Product, Solutions, Pricing.
EditExportApprove
Don't. Put commands in it. Edit, Export and Approve are actions for a menu or toolbar, not places to go.
Do. Give each panel link a title and one line that says what's there.
Don't. Fill a panel with a dozen bare links. If a section needs that many, give it a page.

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 an aria-label when the page has more than one navigation landmark.
  • Triggers expose aria-expanded and control their panel. Panel links are plain anchors, so they announce and open like links.
  • Mark the current page with active and aria-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.
Keyboard interactions
KeysAction
TabMoves into the row, then into an open panel's links.
EnterOpens the focused trigger's panel, or follows a link.
SpaceOpens the focused trigger's panel.
↓Opens the panel and moves focus into it.
←→Moves between triggers and links in the row (roving focus).
EscCloses the panel and focuses its trigger.

Design tokens#

Design tokens
TokenUsed for
--mutedTrigger and link hover, open and active fills
--popoverPopup background
--popover-foregroundPopup text
--ringFocus ring at 50%
ring-foreground/10The popup's 1px ring
shadowThe popup's drop shadow
--radius-lg10px trigger and popup corners

API reference#

NavigationMenu

The root. Renders the list you pass plus the shared positioner, popup and viewport.

Other props spread onto Base UI NavigationMenu.Root (<nav>).

Props of NavigationMenu
PropTypeDefaultDescription
align"start" | "center" | "end""start"Aligns the popup to its trigger.
valueany | nullNo defaultControlled open item. null closes.
defaultValueany | nullnullUncontrolled initial open item.
onValueChange(value, eventDetails) => voidNo defaultCalled when the open item changes.
delaynumber50Milliseconds before hover opens a panel.
closeDelaynumber50Milliseconds before leaving closes it.
orientation"horizontal" | "vertical""horizontal"Arrow-key direction between triggers.

NavigationMenuList

The row of items.

Other props spread onto Base UI NavigationMenu.List.

No props of its own.

NavigationMenuItem

One top-level entry.

Other props spread onto Base UI NavigationMenu.Item.

Props of NavigationMenuItem
PropTypeDefaultDescription
valueanyNo defaultIdentifies the item for controlled use. Generated if omitted.

NavigationMenuTrigger

Opens the item's panel. Appends the chevron.

Other props spread onto Base UI NavigationMenu.Trigger (<button>).

No props of its own.

NavigationMenuContent

The item's panel, rendered into the shared viewport. 4px padding.

Other props spread onto Base UI NavigationMenu.Content.

No props of its own.

navigationMenuTriggerStyle

The trigger's class string, for styling a plain link in the row like a trigger.

No props of its own.

NavigationMenuPositioner

The portal, positioner, popup and viewport. NavigationMenu already renders one.

Other props spread onto Base UI NavigationMenu.Positioner.

Props of NavigationMenuPositioner
PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""bottom"Side of the trigger.
sideOffsetnumber8Gap from the trigger in px.
align"start" | "center" | "end""start"Alignment to the trigger.
alignOffsetnumber0Offset along the alignment axis.

NavigationMenuIndicator

Intended as an arrow under the open trigger. See known gaps.

Other props spread onto Base UI NavigationMenu.Icon.

No props of its own.

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.