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.
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) → Positioner →
Popup → (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).
plainis the bar row: control height, a label, an optional chevron. It is aTrigger's default.cardis the block inside a panel: a padded target holding aLinkTitleand aLinkDescription. It is aLink'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.
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
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
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
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.
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
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
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
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
| Key | Behaviour |
|---|---|
| Tab | Moves 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 Left | Horizontal orientation: moves between items in the bar. Mirrored under RTL. |
| Arrow Down then Arrow Up | Vertical orientation: moves between items in the bar. With a horizontal bar, Arrow Down on an open trigger moves into its panel. |
| Enter then Space | Opens the focused trigger's panel, or follows the focused link. |
| Home then End | Moves to the first or last item in the bar. |
| Esc | Closes 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.
| Property | Controls | Default |
|---|---|---|
--forte-navigation-menu-gap | Space between adjacent items in the bar | var(--forte-space-1) |
--forte-navigation-menu-row-height | Height of a plain row | var(--forte-control-h-md) |
--forte-navigation-menu-row-px | Inline padding inside a plain row | var(--forte-control-px-md) |
--forte-navigation-menu-row-gap | Gap between a plain row's label and its icon | var(--forte-control-gap) |
--forte-navigation-menu-row-radius | Corner radius of a plain row | var(--forte-radius-control) |
--forte-navigation-menu-row-fg | Text colour of a plain row at rest | var(--forte-color-foreground) |
--forte-navigation-menu-row-bg-hover | Background of a hovered plain row | var(--forte-color-panel-hover) |
--forte-navigation-menu-row-bg-open | Background of the row whose panel is open, and of the current page's row | var(--forte-color-primary-soft) |
--forte-navigation-menu-row-fg-open | Text colour of those same two rows | var(--forte-color-primary-text) |
--forte-navigation-menu-row-font-size | Font size of a plain row | var(--forte-font-size-2) |
--forte-navigation-menu-row-font-weight | Font weight of a plain row | var(--forte-font-weight-medium) |
--forte-navigation-menu-icon-size | Size the chevron is drawn at, in both variants | var(--forte-space-4) |
--forte-navigation-menu-backdrop-z-index | Stacking order of the backdrop | 49 |
--forte-navigation-menu-z-index | Stacking order of the positioner. 50 is the band Menu, Popover, Tooltip and Select sit in — anchored popups all belong above a Dialog's 40 | 50 |
--forte-navigation-menu-bridge | Depth of the invisible bridge across the gap between the bar and the panel, so the pointer can travel to the panel without closing it | var(--forte-space-2) |
--forte-navigation-menu-move-duration | How long the panel takes to travel to another trigger, and to resize between two panels | var(--forte-duration-move) |
--forte-navigation-menu-move-ease | The curve it travels on | var(--forte-ease-emphasized) |
--forte-navigation-menu-bg | Panel surface colour | var(--forte-color-overlay) |
--forte-navigation-menu-fg | Panel text colour | var(--forte-color-foreground) |
--forte-navigation-menu-radius | Corner radius | var(--forte-radius-surface) |
--forte-navigation-menu-shadow | Panel 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-width | Width of the hairline that separates the panel from the page | 1px |
--forte-navigation-menu-border-color | Colour of that hairline. The arrow's outline follows it. | var(--forte-color-border) |
--forte-navigation-menu-content-p | Padding inside a panel | var(--forte-space-2) |
--forte-navigation-menu-content-gap | Gap between the cards in a panel | var(--forte-space-1) |
--forte-navigation-menu-column-width | Width 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 query | 15rem |
--forte-navigation-menu-content-inset | Space left between the panel and the window edge when the columns above would not fit | var(--forte-space-5) |
--forte-navigation-menu-card-p | Padding inside a card | var(--forte-space-3) |
--forte-navigation-menu-card-gap | Gap between a card's title and its description | var(--forte-space-1) |
--forte-navigation-menu-card-radius | Corner radius of a card | var(--forte-radius-control) |
--forte-navigation-menu-card-fg | Card text colour at rest | var(--forte-color-foreground) |
--forte-navigation-menu-card-fg-muted | Colour of a card's description line | var(--forte-color-foreground-muted) |
--forte-navigation-menu-card-bg-hover | Background of a hovered card | var(--forte-color-panel-hover) |
--forte-navigation-menu-card-bg-active | Background of the current page's card, and of a card whose nested menu is open | var(--forte-color-primary-soft) |
--forte-navigation-menu-card-fg-active | Text colour of those same two cards | var(--forte-color-primary-text) |
--forte-navigation-menu-card-font-size | Base font size inside a card | var(--forte-font-size-2) |
--forte-navigation-menu-arrow-width | Arrow base width | 1em |
--forte-navigation-menu-arrow-height | Arrow height, and the distance it is pushed out from the panel | 0.5em |
--forte-navigation-menu-travel | How far the panel slides in from, per side | var(--forte-travel-sm) |
--forte-navigation-menu-enter-scale | Scale the panel grows from and shrinks back to | var(--forte-scale-enter) |
--forte-navigation-menu-enter-duration | Length of the opening gesture | var(--forte-duration-normal) |
--forte-navigation-menu-enter-ease | Curve of the opening gesture | var(--forte-ease-spring-snappy) |
--forte-navigation-menu-exit-duration | Length of the closing gesture | var(--forte-duration-fast) |
--forte-navigation-menu-exit-ease | Curve of the closing gesture | var(--forte-ease-exit) |
--forte-navigation-menu-swap-travel | How far the outgoing and incoming panels slide past each other when the popup is handed from one trigger to another | var(--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.
NavigationMenu.Root
| Prop | Type | Default | Description |
|---|---|---|---|
actionsRef | RefObject<NavigationMenuRootActions | null> | A ref to imperative actions. `actionsRef.current.unmount()` removes the popup immediately instead of waiting for its exit transition. | |
children | ReactNode | The `List` of items, and the `Popup` their panels are shown in. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeDelay | number | 50 | How 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. |
defaultValue | Value | null | null | The item whose panel is open on first render, by `value`. |
delay | number | 50 | How 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" | horizontal | Which 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. |
value | Value | null | The item whose panel is open, by `value`. Pass it to control the menu; use `defaultValue` for the uncontrolled form. `null` closes it. |
NavigationMenu.List
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The `NavigationMenu.Item`s that make up the bar. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NavigationMenu.Item
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | A `Trigger` and its `Content`, or a single `Link` for an item that navigates straight out without opening a panel. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
value | any | Identifies this item, and is what `Root`'s `value` / `defaultValue` name. Generated automatically when omitted, so it is only needed to drive the menu programmatically. |
NavigationMenu.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The label, plus anything else the row shows. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
icon | ReactNode | The 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. | |
nativeButton | boolean | true | Whether 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. |
render | ReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, NavigationMenuTriggerState> | Replaces the rendered `<button>` with another element or component. | |
variant | NavigationMenuVariant | plain | How 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. |
NavigationMenu.Icon
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The glyph. `Trigger` supplies a chevron when you do not. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NavigationMenu.Content
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The panel's `Link`s — or a nested `NavigationMenu.Root`, or any markup of your own. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
columns | NavigationMenuColumns | 1 | How 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. |
keepMounted | boolean | false | Keep 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. |
NavigationMenu.Link
| Prop | Type | Default | Description |
|---|---|---|---|
active | boolean | false | Whether this link is the page the user is on. Publishes `data-active` and `aria-current="page"`, so the cue is not colour alone. |
children | ReactNode | The label — usually a `LinkTitle` and a `LinkDescription`. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
closeOnClick | boolean | false | Whether 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. |
href | string | Where the link goes. | |
render | ReactElement<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. | |
variant | NavigationMenuVariant | card | How 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. |
NavigationMenu.LinkTitle
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The link's headline. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NavigationMenu.LinkDescription
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | The supporting line. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. |
NavigationMenu.Popup
Renders the whole floating half of the anatomy — portal, optional backdrop, positioner, popup, optional arrow and viewport — as one part, and takes no children.
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | center | How 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. |
alignOffset | number | OffsetFunction | 0 | Extra offset in pixels along the alignment axis. |
anchor | Element | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | null | An element to position the panel against instead of the active trigger. | |
arrow | boolean | false | Draw 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. |
backdrop | boolean | false | Render 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. |
className | string | Additional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`. | |
collisionAvoidance | CollisionAvoidance | Which 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. | |
collisionPadding | Padding | 8 | Space to keep between the panel and the edge of its collision boundary. |
container | HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null | Render the panel into a different container instead of `<body>`. | |
keepMounted | boolean | false | Keep the popup mounted while the menu is closed. |
positionerClassName | string | Additional 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. | |
side | Side | bottom | Which 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. |
sideOffset | number | OffsetFunction | 8 | Gap 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. |