Primitives
Navigation Menu
A site-navigation header built on the disclosure pattern: buttons that expand panels of links into a shared viewport.
Open a section from the bar and move between them. The active trigger carries data-state="open", and data-motion says which way the viewport slid to reach it.
A <nav> of disclosures, not an ARIA menu. Triggers are buttons with aria-expanded / aria-controls, content panels are landmarks with links, Tab moves through links, Escape closes and returns focus.
Anatomy
<nav forNavigationMenu [(value)]="open" ariaLabel="Main">
<ul forNavigationMenuList>
<li forNavigationMenuItem value="products">
<button forNavigationMenuTrigger>Products</button>
<!-- @if (open() === 'products') { -->
<div forNavigationMenuContent>
<a href="/p/web" forNavigationMenuLink>Web</a>
<a href="/p/mobile" forNavigationMenuLink active>Mobile</a>
</div>
<!-- } -->
</li>
<span forNavigationMenuIndicator></span>
</ul>
<!-- Optional shared surface for mega-menu animations -->
<div forNavigationMenuViewport></div>
</nav>
Examples
Vertical orientation
orientation='vertical' stacks the triggers into a sidebar and swaps the keyboard axis: ArrowUp / ArrowDown move focus across triggers, ArrowRight opens the focused panel. Each panel flies out beside its trigger and the indicator becomes a vertical bar tracking the active row.
Limitations
- Nested submenus are not implemented; they are tracked separately.
API
ForNavigationMenu
| Property | Type | Description |
|---|---|---|
value | model | Two-way bindable. Open item id, or null when nothing is open.Default: null |
orientation | input | Default: 'horizontal' |
dir | input | RTL inverts ArrowLeft / ArrowRight. Default: — |
loop | input | Whether arrow nav wraps. Default: true |
disabled | input | Disables the whole menu. Default: — |
ariaLabel | input | Reactive aria-label for the <nav>.Default: null (and empty string) emits no attribute; prefer native aria-labelledby when a visible label exists |
openDelay | input | ms before hover/focus opens. Default: 200 |
closeDelay | input | ms before pointer-leave closes. Default: 150 |
skipDelayDuration | input | ms after a peer closes during which the next open is instant. Default: 300 |
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forNavigationMenu] | data-state | open | closed | |
[forNavigationMenu] | data-orientation | horizontal | vertical | |
[forNavigationMenu] | data-disabled | present | absent | |
[forNavigationMenuList] | data-orientation | horizontal | vertical | |
[forNavigationMenuItem] | data-state | open | closed | |
[forNavigationMenuItem] | data-disabled | present | absent | |
[forNavigationMenuTrigger] | data-state | open | closed | |
[forNavigationMenuTrigger] | data-disabled | present | absent | |
[forNavigationMenuContent] | data-state | open | closed | |
[forNavigationMenuContent] | data-motion | from-start | from-end | to-start | to-end | |
[forNavigationMenuLink] | data-active | present | absent | |
[forNavigationMenuIndicator] | data-state | visible | hidden | |
[forNavigationMenuIndicator] | data-orientation | horizontal | vertical | |
[forNavigationMenuViewport] | data-state | open | closed | |
[forNavigationMenuViewport] | data-orientation | horizontal | vertical |
data-motion is absent on first open and last close, where there is no peer trigger to compare against.
Keyboard
| Key | Behavior | |
|---|---|---|
| Tab | Moves into the trigger row. Inside an open panel, moves through its links. | |
| Enter / Space | Toggles the focused trigger. | |
| ArrowDown (horizontal) / ArrowRight (vertical) | Opens the focused trigger. | |
| ArrowLeft / ArrowRight (horizontal) | Moves focus across triggers. | |
| ArrowUp / ArrowDown (vertical) | Moves focus across triggers. | |
| Home / End | Jump to first / last enabled trigger. | |
| Escape | Closes and returns focus to the trigger. |
Accessibility
Implements the WAI-ARIA Disclosure Navigation Menu pattern.
- Not an ARIA menu. This implements the disclosure pattern:
<nav>+ buttons + landmark panels. ARIArole="menu"is for application menus where Tab leaves but arrows do everything. Site navigation expects Tab to move through links, which is what this primitive supports. - Focus alone does not open. A trigger opens on mouse hover, click, Enter / Space, or the cross-axis arrow (ArrowDown horizontal / ArrowRight vertical), but never on plain focus. This matches the APG disclosure-navigation pattern: Tabbing across the trigger row does not auto-expand panels, and the return-focus after Escape cannot synchronously re-open the panel that just closed.
- Hover is a mouse affordance. The
pointerenter/pointerleaveopen-and-close path on both the trigger and the panel is gated topointerType === 'mouse'. Touch and pen fire those events around every tap, so honouring them would open a panel mid-press and let the follow-up tap toggle it straight back shut; on those devices a tap drives the panel through the nativeclickinstead. - Trigger labels are mandatory. Each
[forNavigationMenuTrigger]needs visible text or anaria-label. The directive does not invent one. - Disabled triggers stay focusable. A trigger is disabled by its own item's
[disabled]or by the root's (the two are OR'd), and the state is reflected on the trigger asaria-disabled="true"+data-disabled, never as the nativedisabledattribute. The trigger therefore keeps its place in the tab order and screen readers still announce it as unavailable, while clicks, hover-opens and keyboard activation are no-ops and arrow navigation skips it. Key disabled styling off[data-disabled], not:disabled. The[forNavigationMenuItem]host's owndata-disabledreflects its per-item[disabled]only; the merged state lives on the trigger. - Content panels are mounted via
@if. The directive does not apply[hidden]; visibility is the consumer's call. Useanimate.enter/animate.leavefor transitions. aria-controlsis emitted only while the panel is there to point at. The trigger drops the attribute entirely, rather than emitting it empty, whenever the open item's[forNavigationMenuContent]is not mounted, so the reference never dangles. The pairing is resolved synchronously in the first render pass, which is what makes it present in server-rendered markup too: a screen reader reading the pre-hydration document gets the samearia-controls/aria-labelledbylinkage a hydrated one does.- Indicator follows the active trigger. A
ResizeObserver(browser-only) watches the active trigger and the surrounding list and re-measures only when the active trigger switches or one of those boxes resizes. Measurement is reactive, not per-render polling. Consumers drive the visual via the--for-navigation-menu-indicator-x|y|width|heightcustom properties. data-stateon the root. The[forNavigationMenu]host reflectsdata-state="open"whenever any item is open and"closed"otherwise. The attribute uses the same vocabulary as the trigger / content / item / indicator pieces and is useful for top-level CSS hooks (e.g. dimming the rest of the page while the menu is open).- Tab-out closes. Per APG, moving focus out of the navigation closes any open panel. There is one containment rule behind it: focus counts as inside while it is on the nav host, the Viewport or the active panel, or while it is inside any overlay stacked on top of them, so a popover or hovercard opened from a panel never dismisses the panel it is anchored to. Moving between those never dismisses. It holds wherever the Viewport is stamped: a panel re-parented outside the
<nav>is still part of the surface. It also holds for a leave that reports no destination at all (focusoutwith anullrelatedTarget: focus leaving the document, or landing on a non-focusable area), which is resolved againstdocument.activeElementonce the focus move has settled. Pressing a non-focusable region inside the panel drops focus to<body>without leaving the widget, so it does not dismiss. When a dismissible overlay of your own is open on top of the navigation (a dialog opened from a mega-menu link), that overlay owns the focus leave and the navigation stays open behind it until its own turn comes.
Styling
forty-cdk ships no styles: put your own class on each piece and key your CSS off the data-* attributes listed under Data attributes, not off the for* selectors (Styling forty-cdk explains why).
CSS custom properties
[forNavigationMenuIndicator] exposes the active trigger's geometry (relative to [forNavigationMenuList]) so the indicator visual can be driven entirely from CSS. The optional shared [forNavigationMenuViewport] exposes the active panel's natural size so consumers can transition width / height between trigger groups.
| Property | Meaning | |
|---|---|---|
--for-navigation-menu-indicator-x | Horizontal offset of the active trigger (px). | |
--for-navigation-menu-indicator-y | Vertical offset of the active trigger (px). | |
--for-navigation-menu-indicator-width | Active trigger width (px). | |
--for-navigation-menu-indicator-height | Active trigger height (px). | |
--for-navigation-menu-viewport-width | Active content's natural width (px), on the Viewport. | |
--for-navigation-menu-viewport-height | Active content's natural height (px), on the Viewport. |
.navigation-menu-trigger svg {
transition: transform 150ms;
}
.navigation-menu-trigger[data-state='open'] svg {
transform: rotate(180deg);
}
.navmenu-indicator {
transform: translateX(var(--for-navigation-menu-indicator-x));
width: var(--for-navigation-menu-indicator-width);
transition:
transform 200ms,
width 200ms;
}
.navmenu-indicator[data-state='hidden'] {
opacity: 0;
}
Wrapping in a design system
Subclass the root and re-provide FOR_NAVIGATION_MENU_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.