Add a NavigationMenu component family
NavigationMenu: native site navigation with coordinated panels
Implementation specification, amended 2026-09-04. The original body (2026-08-31) transferred the confirmed Draft 9 design as amended in Draft 11. It has since been audited against the codebase, the standards and four prior-art libraries, and the maintainer settled twenty further decisions. This revision folds those in, corrects three sentences the audit proved false, and records the implementation base. All decisions below are settled. The API is not shipped and the release gates remain unpassed. Further accepted contract changes belong in this body; comments and research notes do not override it.
Problem and goals
The menubar-navigation example combines a category destination with panel disclosure. On touch, activating its Services link navigates without providing a way to open that panel. @michaelsawyers reported this in #6992. Build a dedicated family with separate native link and disclosure actions, coordinated rich panels, and the example's focus and keyboard paths. Keep existing Menu and Menubar roles and defaults unchanged.
Support plain links, button-only entries, category links with sibling disclosure buttons, and one coordinated open entry per navigation root. Include shared floating presentation. Preserve native navigation, logical Tab order, accessible panel relationships, retained child state, and initial server links for the navigation's own links.
Target framework-independent state in the existing @ariakit/components package, React rendering in @ariakit/react-components, and public React exports in @ariakit/react, following existing family entry points and store conventions. Do not create a new package or promise simultaneous Solid support. Keep the core store DOM-free so a later Solid port is mechanical. React 17, 18, and 19 remain the supported React range; React 17 needs the existing explicit-ID route for stable initial server relationships.
examples/menubar-navigation migrates to this family as part of the work, and #6992 closes on that basis. Its existing browser tests define the behaviour the migration must preserve, but they assert through menu ARIA roles, so none of them transfer unchanged and all are rewritten against the new family's native structure. The existing suite also omits the wraparound sequence and the touch and modifier cases, so those are new coverage rather than inherited.
Non-goals
This is not an application-menu tree, router, automatic URL matcher, mobile drawer, full no-JavaScript disclosure, or replacement for existing Menu/Menubar. Multiple simultaneously open top-level entries are outside the root model. Unhosted inline Content, a separate NavigationMenuViewport, viewport or inline mode props, public per-item stores, Item geometry overrides, and a new focus-strategy enum are outside v1. There is no new same-focus reopening shortcut on links. NavigationMenuHoverTrigger and NavigationMenuAnchor are outside v1; they may be considered in the future, but no later release is promised.
Item-local floating presentation is deferred out of v1 by D9 as amended. Panel bodies inside initial server HTML are no longer promised; see the server section.
The separate Composite focus/scroll defect in #7360 was fixed independently in bf64034 and shipped in @ariakit/[email protected]. It is not re-litigated here, and its existing regression must survive the migration.
Confirmed decision record
| ID | Accepted decision | Contract |
|---|---|---|
| D1 | Dedicated native NavigationMenu | Use nav, lists, links, and buttons. Do not add menu, menubar, menuitem, or automatic dialog roles, and do not emit aria-haspopup. |
| D2 | Visible-focus opening | Reveal the associated panel on visible keyboard focus without moving focus into it. Cancel older entry-focus work. An accepted Escape suppresses immediate visible-focus reopening. |
| D3 | One root openId |
A string selects Item id; null means none. Child controls request root changes. Focused-child IDs, panel IDs, current-page state, and exit-animation state remain separate. |
| D4 | Shared presentation in v1 | Shared presentation is required and is the only presentation in v1. |
| D6 | Neighboring-category panel exit | In horizontal orientation, Left/Right from eligible panel controls reaches the neighboring entry's primary control, independent of the control used to enter the panel. In vertical orientation the axes transpose and the inline pair enters and closes the panel instead; there is no cross-entry panel exit. |
| D7 | Supplemental arrows enabled | Enable matching arrows and Home/End by default. A root option can disable them without removing keyboard access to panels. Page keys are not claimed. |
| D8 | One primary control per entry | Use the top-level category link, otherwise the disclosure button. A sibling button stays native, named, exposed, and clickable but is excluded from Tab and family arrow handling/traversal. |
| D9 | Two panel parts; local deferred | NavigationMenuPopover is the host; NavigationMenuContent is the per-entry panel. Shared support ships in v1. Item-local floating is deferred to its own design pass, because a second host duplicates every override point and adds a second Dialog lifecycle. The shared/local discriminator is item scope, never the presence of children, so local support remains additive. |
| D10 | Root store plus entry reference | Detached parts use the same root store and itemId referencing Item id. Item itself has only id; no public Item store/provider is added. |
| D11 | Required host, no routing prop | Content uses its matching Popover. Scope establishes the host before DOM registration. Missing configuration does not select inline rendering. No viewport or inline opt-out in v1. |
| D12 | Surface geometry | No Item placement/shift inheritance. Keep geometry with the positioned surface and its owner store. The application maps the mounted entry to shared-surface settings. |
| D13 | Primary focus return | When default restoration is eligible, return to the entry's primary control regardless of entry method or secondary-button activation. Preserve outside focus and existing finalFocus/autoFocusOnHide overrides. |
| D14 | Item owns hover and the positioning rectangle | Item is the default hover region and supplies its own element rectangle for panel positioning. It remains structural/unfocused. showOnHover=false disables hover opening only. No separate HoverTrigger or Anchor component in v1. |
| D15 | Implementation base | The family composes the existing Menu and Menubar hooks and overrides their semantics. It does not fork their behaviour and does not change Menu or Menubar's own output. |
| D16 | Axis, direction and looping | Ship orientation (default "horizontal", never the composite's "both"), rtl and focusLoop on the root store in v1. focusLoop defaults to true, overriding the composite store's false, and applies to both traversal sequences. rtl is explicit and is never inferred from dir="rtl". |
D5 is retired, without an accepted answer. It compared ways to extend Menubar and became inapplicable under D1. Do not reuse its identifier. No public-policy decision remains unanswered in this record.
Amendments to the 2026-08-31 body
Three sentences in the original body were proved false by reproductions run against the settled decisions. Each is replaced below and in its section.
| Was | Now | Why |
|---|---|---|
"The real button exposes aria-expanded and aria-controls for its own panel, not the shared shell." |
The button computes both from accepted root state and the per-entry panel id, and the family re-emits them owner-scoped. | With one shared host and one panel store, useDisclosure derives aria-controls from a store-level content element, so every entry's button carried the same value and the panel's aria-labelledby followed one arbitrary entry. |
"A null finalFocus uses the fallback; it does not disable restoration." |
Content supplies the entry's primary control as its own default finalFocus, latched while the entry is mounted. A null or absent finalFocus resolves to that primary control rather than to Dialog's disclosureElement. |
Under D8 the disclosure element is the excluded sibling button. Reproduced: an application Dialog opened from inside a panel and then closed restored focus to the first entry's tabindex="-1" button while the open entry was the third, by escalating through [data-dialog] and [aria-controls~=]. |
| Down/Up panel entry was assumed to arrive with the composed base. | The family adds its own Down/Up handler on the primary control. | getInitialFocus and its keydown live in the menu button hook. Under D8 the primary is a link, so those keys did nothing at all, and a horizontal composite does not even prevent them. |
A fourth claim in the original body is withdrawn as too strong rather than false. The Escape suppression concern about a resting pointer does not apply: hover intent is a module-global flag that requires real pointer movement, and a keydown resets it, so an accepted Escape leaves the panel closed while the pointer rests. Suppression is scoped to focus reopening only.
Implementation base and override contract
D15 fixes the base: the family composes Menu and Menubar. That decision carries a fixed set of overrides, and the mechanism for applying them is itself part of the contract.
Removing inherited ARIA. NavigationMenu parts call the Menu and Menubar use* hooks and delete the overridden keys from the returned props object before createElement, following the existing destructure of aria-labelledby out of the menu list hook in menu.tsx. Passing undefined for these props is defined by forwardRef to mean "omitted" and must not be used as a removal mechanism. Passing the value down through a use* hook is also invalid, because an intermediate strip lets a lower layer re-apply its own default: verified, a menu hook given role: undefined returned role="dialog" and its button returned aria-haspopup="dialog". composite={false} is not an option either, because the flag that suppresses the role also short-circuits the composite and destroys the traversal D6 depends on.
The override set. Roles on the menubar, list, item, button and the dialog underneath. aria-orientation. aria-haspopup, together with hideOnClick={false} wherever a disclosure sits inside a panel, because hideOnClick is keyed on aria-haspopup. Typeahead on both the primary and the panel. Page keys through moveOnKeyPress. Roving tabindex through tabbable on every primary, set internally and never exposed, with virtualFocus kept off because tabbable has no effect under it. The menu button's Enter/Space autofocus. The Escape wrapper. preserveTabOrderAnchor, which otherwise defaults to the disclosure element and falls back to the anchor element. The outside-interaction region. aria-controls and aria-labelledby, re-emitted owner-scoped.
What the base does not provide. Menu and Menubar keep part of their cross-entry coordination in the DOM, reading aria-expanded and aria-controls back off elements. Those two attributes survive here, re-emitted owner-scoped, so most of that path keeps working. The only part that depends on a removed attribute is the final [role=menuitem][aria-expanded=true] lookup inside menuHasFocus, which fires only for a disclosure nested inside an open panel, a composition v1 does not have. This was measured: on the current example, removing every menu role and aria-haspopup from the DOM before each interaction produced behaviour identical to the baseline. The family still owns entry switching against openId rather than relying on the inherited path.
Two defects in Menu are tracked separately, both reproduced against the published release with public API only. #7410 blocks the vertical half of D16. #7409 blocks the rich-nested-widget guarantee in both orientations, because in a horizontal menubar it takes Left/Right away from a focused text field inside an open panel. Both ship as Menu bug fixes in the same release.
Public parts and ownership
The following names describe the target API, not exports available in the inspected release. Reuse Ariakit's existing native props, polymorphic render, ref merging, store setters, event types, and package boundaries.
| Part | Responsibility |
|---|---|
NavigationMenuProvider, useNavigationMenuStore, useNavigationMenuContext |
One public root store; openId, defaultOpenId, setOpenId, read-only mountedId, root-wide arrowNavigation, orientation, rtl, focusLoop, placement, and hover delays. Provider adds no DOM. |
NavigationMenu |
Named native nav; keyboard and navigation scope. |
NavigationMenuItem |
Structural li; stable id; root/item context; default showOnHover; its element supplies the positioning rectangle. It is not a focus target and is not required for every entry. |
NavigationMenuLink |
Real destination anchor, in its top-level or panel scope. A plain link requires no panel Item identity, and a top-level link outside any Item registers itself as that entry's primary. |
NavigationMenuButton, NavigationMenuButtonArrow |
Real type="button" disclosure; optional noninteractive decoration. |
NavigationMenuContent |
One panel's ID, native content, visibility, retained lifetime, dismissal policy, and focus policy. Accept contextual association or store plus itemId. |
NavigationMenuPopover |
Positioning and attachment host, shared per root. It does not own a second focus-restoration lifecycle. Children are decoration only. |
NavigationMenuArrow |
Optional pointer rendered as a child of the shared surface. |
createNavigationMenuStore is the framework-independent factory and lives behind an @ariakit/components subpath export, following every other family. It is not a @ariakit/react export.
arrowNavigation, orientation, rtl and focusLoop belong to the root Provider/store, so detached parts share the same policy. showTimeout and hideTimeout also belong to the root; preserve explicit zero and existing Hovercard delay defaulting. The target example can explicitly use 100 ms show and 250 ms hide delays; these example values are not measured universal recommendations. Do not add a v1 focus-opening opt-out that can remove the selected split entry's keyboard route.
Keep shift, gutter, portal/wrapper options, and other DOM geometry on the actual Popover surface. Placement follows the existing surface-owner store setting, defaulted from orientation and rtl as described in the keyboard section, with an explicit author placement still winning. Do not add Item placement or offset overrides. For the shared host, derive per-entry values from mountedId, not from openId and not from a pending or stale request: openId flips to the next value at the instant an exit begins, while the outgoing panel is still on screen.
The associated NavigationMenuItem element supplies the positioning rectangle in v1. Disabling Item hover does not change that rectangle. Do not substitute the disclosure button or primary control as a positioning fallback. A detached Content resolves its Item through the same root and itemId; an Item element that is not attached yet is pending, not a reason to borrow another control's rectangle. This differs from existing Popover's disclosure fallback.
D12 still keeps placement and offsets on the positioned surface and its owner store. Item supplies the reference rectangle, not placement or offset overrides. The logical Tab-order anchor remains the primary link, or primary button in a button-only entry; the family passes it explicitly as preserveTabOrderAnchor so a split entry does not inherit Popover's disclosure-first Tab anchor.
Shared composition
The examples use the future family named exports from @ariakit/react. The shared host is outside Item scope and inside the same navigation root. Ordinary lists supply the panel's native structure.
function PrimaryNavigation() {
const prefix = React.useId();
const servicesId = prefix + "-services";
const blogId = prefix + "-blog";
const [openId, setOpenId] = React.useState<string | null>(null);
// Every option goes on the store the application owns. A `store` prop on a
// Provider is a sync source, so options passed there would land on a second,
// derived store instead of this one.
const navigation = useNavigationMenuStore({
openId,
setOpenId,
placement: "bottom-start",
showTimeout: 100,
hideTimeout: 250,
});
// Derive surface settings from the mounted entry, not from openId, so the
// surface does not move while an outgoing panel is still animating out.
const mountedId = useStoreState(navigation, "mountedId");
const shift = mountedId === servicesId ? -96 : mountedId === blogId ? -192 : 0;
return (
<NavigationMenuProvider store={navigation}>
<NavigationMenu aria-label="Primary">
<ul>
<NavigationMenuItem id={servicesId} className="navigation-entry">
<NavigationMenuLink href="/services">Services</NavigationMenuLink>
<NavigationMenuButton aria-label="Services links">
<NavigationMenuButtonArrow />
</NavigationMenuButton>
<NavigationMenuContent>
<ul>
<li>
<NavigationMenuLink href="/services/web">
Web Development
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem id={blogId}>
<NavigationMenuButton>Blog</NavigationMenuButton>
<NavigationMenuContent>
<ul>
<li>
<NavigationMenuLink href="/blog/latest">
Latest posts
</NavigationMenuLink>
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<li><NavigationMenuLink href="/contact">Contact</NavigationMenuLink></li>
</ul>
<NavigationMenuPopover gutter={8} shift={shift}>
<NavigationMenuArrow />
</NavigationMenuPopover>
</NavigationMenu>
</NavigationMenuProvider>
);
}The real button exposes aria-expanded, and the family emits aria-controls on it pointing at that entry's own panel. Both are computed during render from accepted root state and the per-entry panel id rather than read back from a shared content element, so exactly one element in the navigation carries aria-controls at any moment and it is the entry that owns the mounted panel. The shared panel's aria-labelledby points at that same entry's primary. The category link retains href and optional aria-current; it does not copy the button's disclosure attributes. Keep the link and button as siblings. A shared focus outline can surround their header group while actual focus remains on the link. Do not make the wrapper a third button or hide the secondary button from assistive technology; only its decorative graphic may be hidden.
Detached association
Source: ariakit/ariakit