Skip to content

Navigation Menu

A site navigation bar: a row of items, some of which open a panel of links. Reach for it when every row navigates — the product menu across the top of a marketing site, the section switcher in a docs header. If the rows run commands instead — duplicate, export, sign out — that is a Menu, a different set of ARIA roles and a different set of keyboard expectations.

navigation-menu/basic.tsx

The thing to notice is that there is one popup. Every item's Content is written up in the list, and Base UI moves the active one into a single shared surface — so the panel slides between triggers and resizes to whatever is now inside it, instead of one dropdown closing while its neighbour opens.

Import

import { NavigationMenu } from "@forte-ui/react";

Anatomy

Base UI's anatomy, with the floating half collapsed into one part:

<NavigationMenu.Root>
  <NavigationMenu.List>
    <NavigationMenu.Item>
      <NavigationMenu.Trigger>
        {/* an Icon is rendered for you */}
      </NavigationMenu.Trigger>
      <NavigationMenu.Content>
        <NavigationMenu.Link>
          <NavigationMenu.LinkTitle />
          <NavigationMenu.LinkDescription />
        </NavigationMenu.Link>
      </NavigationMenu.Content>
    </NavigationMenu.Item>
  </NavigationMenu.List>

  <NavigationMenu.Popup />
</NavigationMenu.Root>

<NavigationMenu.Popup> renders Portal → (Backdrop) → PositionerPopup → (Arrow) → Viewport as one part, the same way Menu.Popup does. Those elements are meaningless apart and each has a job the styles depend on: the positioner owns the travel between triggers and publishes --available-width and --transform-origin, the popup owns the surface and its resize, and the viewport clips the two panels sliding past each other. It takes no children — the panels come from the Content parts up in the list — and there is exactly one per Root.

Examples

Two variants, two parts

Trigger and Link share one variant axis, because the two are interchangeable in both places they appear. A bar item either opens a panel (Trigger) or navigates straight out (Link); a panel row either opens a nested menu (Trigger) or navigates (Link).

  • plain is the bar row: control height, a label, an optional chevron. It is a Trigger's default.
  • card is the block inside a panel: a padded target holding a LinkTitle and a LinkDescription. It is a Link's default.

Both draw from the same set of tokens per variant, which is what keeps a plain link and the triggers beside it exactly level — the classic symptom of styling the two parts separately is a bar where they differ by a pixel.

Columns

columns on Content lays the panel out in one, two or three columns of --forte-navigation-menu-column-width.

navigation-menu/columns.tsx

The grid drops to fewer columns on its own when the panel is narrower than one column, so a three-column panel becomes a single column on a phone without a media query. The panel's width is an absolute measure rather than a percentage because the popup around it is sized from it — a percentage would be circular — and it is capped again by --forte-navigation-menu-content-inset from the window edge, which is a hard bound that holds on the very first frame, before the positioner has published --available-width.

A panel taller than the space between the bar and the edge of the window scrolls rather than being clipped: Content is capped at --available-height and scrolls inside the popup, which is capped at the same height. Both are published by the positioner. Native scrollbars stay visible while a panel is transitioning, so wrap a long panel in a Scroll Area when that matters.

An arrow

navigation-menu/arrow.tsx

Off by default, and worth turning on when the panel is much wider than the trigger that opened it: the wedge slides along with the panel on the same timing, and is then the clearest thing on screen naming which item is open.

The current page

navigation-menu/active.tsx

active on a Link publishes data-active and aria-current="page", so the cue is not colour alone. In a real app it comes from the router — usePathname() in Next.js, useLocation() in React Router.

Vertical

navigation-menu/vertical.tsx

orientation="vertical" swaps which arrow keys move between triggers, and changes which axis the panel swap animates along. A vertical bar's panel belongs beside it rather than under it, so pass side="inline-end" to the popup.

Nested menus

A whole Root can be nested inside a Content, with its own Popup.

navigation-menu/nested.tsx

The nested root renders a <div> rather than a second <nav> — one <nav> inside another would announce two navigation landmarks for one region. variant="card" on the nested trigger is what makes it sit level with the links around it, and it takes an inline-axis chevron rather than the downward one, because that is the direction its panel actually arrives from. The chevron mirrors under RTL through --forte-direction.

Controlled

navigation-menu/controlled.tsx

value: null

The open panel is the value: a nullish value means closed, so there is no separate open prop to keep in step with it. Give each Item a value when something outside the bar has to open a particular panel — otherwise Base UI generates one.

Client-side routing

navigation-menu/custom-links.tsx

render hands the <a> to the router's own link component. Pass href to both: Base UI reads it to decide the element is a link, and the router reads it to route. closeOnClick is off by default, because a link that opens a new tab leaves this page and its menu standing — turn it on for a same-tab navigation, and for a client-side route change that would otherwise leave the panel hanging over the new page.

Placement

navigation-menu/placement.tsx

side and align place the panel against the active trigger. Both are hints: the panel flips to the opposite side or shifts along the alignment axis rather than overflow the viewport. align defaults to "center" here rather than Menu's "start" — a panel serving the whole bar should look like it hangs from whichever trigger is open, not from one end of the row.

Pass collisionAvoidance={{ side: "none" }} to stop a panel flipping above a header bar in a short window; it will shrink and scroll instead of appearing on the wrong side of the thing it belongs to.

Accessibility

Keyboard interactions
KeyBehaviour
TabMoves focus into the bar, and then out of it. The bar is a single tab stop — the other items are reached with the arrow keys.
Arrow Right then Arrow LeftHorizontal orientation: moves between items in the bar. Mirrored under RTL.
Arrow Down then Arrow UpVertical orientation: moves between items in the bar. With a horizontal bar, Arrow Down on an open trigger moves into its panel.
Enter then SpaceOpens the focused trigger's panel, or follows the focused link.
Home then EndMoves to the first or last item in the bar.
EscCloses the panel and returns focus to the trigger that opened it.

Panels open on hover as well as press, which is what separates a navigation menu from a Menu: the bar is a place to browse, so once one panel is up the others follow the pointer along the row. Only the first open waits for delay.

That gap between the bar and the panel is real space, and a pointer crossing it is over neither. The positioner draws an invisible bridge across it — --forte-navigation-menu-bridge, paired with the popup's sideOffset — so reaching for a link does not close the panel on the way. Change one and change the other.

The popup's viewport clips — that is what makes a swap read as one panel replacing another rather than two crossing in the open — so every link carries data-focus-inset and draws its focus ring inside its own box, where overflow cannot shave it off (SC 2.4.11). Rows are floored at --forte-target-min so a dense preset cannot take them under 24×24 (SC 2.5.8).

Under forced colors the open trigger and the current page's link paint Highlight / HighlightText explicitly rather than letting their background be replaced with Canvas, which would leave both states indistinguishable from every other row. The popup carries .forte-hc-surface, whose transparent border becomes a system-coloured boundary once the shadow is stripped, and the arrow is two flat SVG paths rather than the traditional CSS-border triangle — a border triangle degrades into a filled rectangle when every border colour is forced to CanvasText.

Motion

Three movements, all pure CSS, none of them running JavaScript per frame.

The panel travels. Base UI writes the positioner's top / left / right / bottom as inline styles and rewrites them whenever another trigger takes the popup over; the stylesheet transitions those four properties on --forte-duration-move. [data-instant] drops the transition for the changes that are not a considered move — a dismissal, or a first open with no previous position to travel from.

The popup resizes. Base UI measures each panel and publishes --popup-width and --popup-height as custom properties on the popup, which consumes them as width and height and transitions both on the positioner's timing, so the travel and the resize read as one gesture. Both properties are absent on the first open, which makes the declaration invalid at computed-value time and leaves width at auto — that is how the popup gets its first measurement, and why neither var() has a fallback.

The panels cross. Content carries data-activation-direction naming where the new trigger sits relative to the old one, so the incoming panel starts on that side and the outgoing one leaves towards the opposite. The two are overlapped rather than stacked — Base UI takes the outgoing one out of flow with an inline position: absolute, and the viewport is its containing block — so the popup is never as tall as both at once.

That direction is one of only two places in the library where an inline-axis distance is not multiplied by --forte-direction. The usual rule assumes the intent is logical ("enter from the start edge"); this one is already physical, because Base UI derives the word from getBoundingClientRect(). Flipping it under RTL would throw the panel away from the trigger the user just reached for.

Enter and exit are transitions on [data-starting-style] / [data-ending-style], never keyframes, so a panel dismissed while it is still opening reverses instead of snapping.

There is no prefers-reduced-motion block in the stylesheet at all. The tokens collapse their own geometry: --forte-travel-sm and --forte-travel-lg become 0px, --forte-scale-enter becomes exactly 1, and --forte-duration-move becomes 1ms — so the panel appears in place, swaps without sliding, and jumps between triggers, with the opacity fade that reduced-motion users actually benefit from still intact.

Theming

The bar's knobs are declared on Root; everything the panel needs — surface, layout, card rows, the arrow, the motion — is declared on Popup. That split is the portal: the popup is rendered into <body>, so nothing set on Root can reach it, and nothing set on the popup can reach the bar.

Within each half the knobs are declared once, on the element every consumer of them is a descendant of, and the parts that read them deliberately do not redeclare their own defaults — which is what lets one style on Popup retune a whole panel, cards included.

navigation-menu/theming.tsx
Theming tokens for NavigationMenu
PropertyControlsDefault
--forte-navigation-menu-gapSpace between adjacent items in the barvar(--forte-space-1)
--forte-navigation-menu-row-heightHeight of a plain rowvar(--forte-control-h-md)
--forte-navigation-menu-row-pxInline padding inside a plain rowvar(--forte-control-px-md)
--forte-navigation-menu-row-gapGap between a plain row's label and its iconvar(--forte-control-gap)
--forte-navigation-menu-row-radiusCorner radius of a plain rowvar(--forte-radius-control)
--forte-navigation-menu-row-fgText colour of a plain row at restvar(--forte-color-foreground)
--forte-navigation-menu-row-bg-hoverBackground of a hovered plain rowvar(--forte-color-panel-hover)
--forte-navigation-menu-row-bg-openBackground of the row whose panel is open, and of the current page's rowvar(--forte-color-primary-soft)
--forte-navigation-menu-row-fg-openText colour of those same two rowsvar(--forte-color-primary-text)
--forte-navigation-menu-row-font-sizeFont size of a plain rowvar(--forte-font-size-2)
--forte-navigation-menu-row-font-weightFont weight of a plain rowvar(--forte-font-weight-medium)
--forte-navigation-menu-icon-sizeSize the chevron is drawn at, in both variantsvar(--forte-space-4)
--forte-navigation-menu-backdrop-z-indexStacking order of the backdrop49
--forte-navigation-menu-z-indexStacking order of the positioner. 50 is the band Menu, Popover, Tooltip and Select sit in — anchored popups all belong above a Dialog's 4050
--forte-navigation-menu-bridgeDepth of the invisible bridge across the gap between the bar and the panel, so the pointer can travel to the panel without closing itvar(--forte-space-2)
--forte-navigation-menu-move-durationHow long the panel takes to travel to another trigger, and to resize between two panelsvar(--forte-duration-move)
--forte-navigation-menu-move-easeThe curve it travels onvar(--forte-ease-emphasized)
--forte-navigation-menu-bgPanel surface colourvar(--forte-color-overlay)
--forte-navigation-menu-fgPanel text colourvar(--forte-color-foreground)
--forte-navigation-menu-radiusCorner radiusvar(--forte-radius-surface)
--forte-navigation-menu-shadowPanel shadow (stripped by the browser in forced-colors mode, which is why the element also carries .forte-hc-surface)var(--forte-shadow-4)
--forte-navigation-menu-border-widthWidth of the hairline that separates the panel from the page1px
--forte-navigation-menu-border-colorColour of that hairline. The arrow's outline follows it.var(--forte-color-border)
--forte-navigation-menu-content-pPadding inside a panelvar(--forte-space-2)
--forte-navigation-menu-content-gapGap between the cards in a panelvar(--forte-space-1)
--forte-navigation-menu-column-widthWidth of one column. Content's columns prop multiplies it, and the grid drops to fewer columns on its own once the panel is narrower than one of them — which is what makes a three-column panel work on a phone without a media query15rem
--forte-navigation-menu-content-insetSpace left between the panel and the window edge when the columns above would not fitvar(--forte-space-5)
--forte-navigation-menu-card-pPadding inside a cardvar(--forte-space-3)
--forte-navigation-menu-card-gapGap between a card's title and its descriptionvar(--forte-space-1)
--forte-navigation-menu-card-radiusCorner radius of a cardvar(--forte-radius-control)
--forte-navigation-menu-card-fgCard text colour at restvar(--forte-color-foreground)
--forte-navigation-menu-card-fg-mutedColour of a card's description linevar(--forte-color-foreground-muted)
--forte-navigation-menu-card-bg-hoverBackground of a hovered cardvar(--forte-color-panel-hover)
--forte-navigation-menu-card-bg-activeBackground of the current page's card, and of a card whose nested menu is openvar(--forte-color-primary-soft)
--forte-navigation-menu-card-fg-activeText colour of those same two cardsvar(--forte-color-primary-text)
--forte-navigation-menu-card-font-sizeBase font size inside a cardvar(--forte-font-size-2)
--forte-navigation-menu-arrow-widthArrow base width1em
--forte-navigation-menu-arrow-heightArrow height, and the distance it is pushed out from the panel0.5em
--forte-navigation-menu-travelHow far the panel slides in from, per sidevar(--forte-travel-sm)
--forte-navigation-menu-enter-scaleScale the panel grows from and shrinks back tovar(--forte-scale-enter)
--forte-navigation-menu-enter-durationLength of the opening gesturevar(--forte-duration-normal)
--forte-navigation-menu-enter-easeCurve of the opening gesturevar(--forte-ease-spring-snappy)
--forte-navigation-menu-exit-durationLength of the closing gesturevar(--forte-duration-fast)
--forte-navigation-menu-exit-easeCurve of the closing gesturevar(--forte-ease-exit)
--forte-navigation-menu-swap-travelHow far the outgoing and incoming panels slide past each other when the popup is handed from one trigger to anothervar(--forte-travel-lg)

To move every navigation menu at once, re-point the global tokens in the Default column instead — --forte-color-*, --forte-radius-*, --forte-space-*, --forte-control-*, --forte-font-* and the motion tokens are only read here, never re-declared, so those do inherit from :root or a theme scope.

The parts also expose their state as data attributes — data-variant, data-orientation, data-columns, data-popup-open, data-active, data-side, data-align, data-activation-direction — so a Tailwind arbitrary variant such as data-[popup-open]:... can target them without a wrapper.

API reference

Each part forwards every prop it does not consume to its Base UI counterpart, so the tables below list what forte-ui adds or changes the default of. Every part takes render for changing the underlying element.

Props for NavigationMenuRoot
PropTypeDefaultDescription
actionsRefRefObject<NavigationMenuRootActions | null>A ref to imperative actions. `actionsRef.current.unmount()` removes the popup immediately instead of waiting for its exit transition.
childrenReactNodeThe `List` of items, and the `Popup` their panels are shown in.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeDelaynumber50How long the panel lingers after the pointer leaves, in milliseconds. It is what lets the pointer cross the gap between the bar and the panel; the stylesheet also bridges that gap, so this stays short.
defaultValueValue | nullnullThe item whose panel is open on first render, by `value`.
delaynumber50How long the pointer must rest on a trigger before its panel opens, in milliseconds. Only the FIRST open waits: once a panel is up, moving along the bar swaps it immediately.
onOpenChangeComplete((open: boolean) => void)Called after the closing transition has finished.
onValueChange((value: Value | null, eventDetails: NavigationMenuRootChangeEventDetails) => void)Called when the open item changes, with the new value and the reason — `trigger-hover`, `trigger-press`, `link-press`, `escape-key` and the rest.
orientation"horizontal" | "vertical"horizontalWhich way the list runs. `vertical` is what a nested menu inside a panel wants — it also swaps the arrow keys that move between triggers, and makes the panel enter from above or below rather than from the side.
valueValue | nullThe item whose panel is open, by `value`. Pass it to control the menu; use `defaultValue` for the uncontrolled form. `null` closes it.
Props for NavigationMenuList
PropTypeDefaultDescription
childrenReactNodeThe `NavigationMenu.Item`s that make up the bar.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
Props for NavigationMenuItem
PropTypeDefaultDescription
childrenReactNodeA `Trigger` and its `Content`, or a single `Link` for an item that navigates straight out without opening a panel.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
valueanyIdentifies this item, and is what `Root`'s `value` / `defaultValue` name. Generated automatically when omitted, so it is only needed to drive the menu programmatically.
Props for NavigationMenuTrigger
PropTypeDefaultDescription
childrenReactNodeThe label, plus anything else the row shows.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
iconReactNodeThe glyph inside the built-in `Icon`. Defaults to a chevron — pointing down on a `plain` trigger, and along the inline axis on a `card` one, which is the direction each variant's panel actually arrives from. Pass `false` to drop it, or a node to replace it.
nativeButtonbooleantrueWhether the rendered element is a real `<button>`. Set it to `false` when `render` replaces the button with something else, so Base UI supplies the keyboard and role behaviour the element does not have natively.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, NavigationMenuTriggerState>Replaces the rendered `<button>` with another element or component.
variantNavigationMenuVariantplainHow loud the row is. `plain` is the bar row; `card` is the block shape a nested menu's trigger takes inside a panel, where it has to sit level with the `Link`s around it.
Props for NavigationMenuIcon
PropTypeDefaultDescription
childrenReactNodeThe glyph. `Trigger` supplies a chevron when you do not.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
Props for NavigationMenuContent
PropTypeDefaultDescription
childrenReactNodeThe panel's `Link`s — or a nested `NavigationMenu.Root`, or any markup of your own.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
columnsNavigationMenuColumns1How many columns the children are laid out in. The grid collapses to fewer on its own when the panel is narrower than `--forte-navigation-menu-column-width`, so a three-column panel becomes one column on a phone without a media query.
keepMountedbooleanfalseKeep the panel in the DOM while it is closed. Off by default; turn it on so the links are in the server-rendered HTML for crawlers.
Props for NavigationMenuLink
PropTypeDefaultDescription
activebooleanfalseWhether this link is the page the user is on. Publishes `data-active` and `aria-current="page"`, so the cue is not colour alone.
childrenReactNodeThe label — usually a `LinkTitle` and a `LinkDescription`.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
closeOnClickbooleanfalseWhether following the link closes the menu. Base UI leaves this off, because a link that opens a new tab leaves the menu's own page standing — turn it on for a same-tab navigation, and for a client-side route change that would otherwise leave the panel open over the new page.
hrefstringWhere the link goes.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<DetailedHTMLProps<AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>, NavigationMenuLinkState>Replaces the rendered `<a>` with another element or component — `render={<Link href="/pricing" />}` is how a framework's router link goes in without losing the menu's keyboard behaviour.
variantNavigationMenuVariantcardHow loud the row is. `card` is the block inside a panel; `plain` is the bar row, for a top-level item that navigates straight out instead of opening a panel — it matches a `plain` `Trigger` so the two sit level in the same list.
Props for NavigationMenuLinkTitle
PropTypeDefaultDescription
childrenReactNodeThe link's headline.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
Props for NavigationMenuLinkDescription
PropTypeDefaultDescription
childrenReactNodeThe supporting line.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.

Renders the whole floating half of the anatomy — portal, optional backdrop, positioner, popup, optional arrow and viewport — as one part, and takes no children.

Props for NavigationMenuPopup
PropTypeDefaultDescription
alignAligncenterHow the panel aligns along that side. Centred on the active trigger, which is the alignment the sliding panel reads best under: it appears to hang from whichever trigger is open rather than from one end of the bar.
alignOffsetnumber | OffsetFunction0Extra offset in pixels along the alignment axis.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullAn element to position the panel against instead of the active trigger.
arrowbooleanfalseDraw a wedge pointing back at the active trigger. Off by default, and worth turning on when the panel is much wider than the trigger that opened it: the wedge slides along with the panel and is then the only thing naming which item is open.
backdropbooleanfalseRender a dimming layer behind the panel. Off by default — a navigation bar is browsed, not modal — but useful on a phone where the panel covers most of the page.
classNamestringAdditional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`.
collisionAvoidanceCollisionAvoidanceWhich collision responses are allowed. Pass `{ side: "none" }` to stop a panel flipping above a header bar when the window is short — it will shrink and scroll instead of appearing on the wrong side of the thing it belongs to.
collisionPaddingPadding8Space to keep between the panel and the edge of its collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullRender the panel into a different container instead of `<body>`.
keepMountedbooleanfalseKeep the popup mounted while the menu is closed.
positionerClassNamestringAdditional class name(s) for the positioner — the absolutely positioned wrapper around the popup, and the element that carries the travel between triggers. This is the only way to reach the four properties declared there: `--forte-navigation-menu-z-index`, `--forte-navigation-menu-bridge`, and the `-move-duration` / `-move-ease` pair the popup inherits for its own resize.
sideSidebottomWhich side of the bar the panel opens on. Flips automatically to avoid collisions unless `collisionAvoidance` says otherwise. `"inline-start"` / `"inline-end"` follow writing direction, but Base UI takes that direction from its own `DirectionProvider` context and not from the `dir` attribute — with no provider mounted it is `"ltr"`, so the two resolve to left and right whatever `dir` says. The library's own CSS reads the attribute, so everything else here flips without it.
sideOffsetnumber | OffsetFunction8Gap in pixels between the bar and the panel. The stylesheet bridges this gap so the pointer can cross it without the panel closing — change `--forte-navigation-menu-bridge` to match if you change this.