Primitives
Menu
The shared menu surface composed by every menu-family primitive: items, checkbox / radio items, groups, separators and submenus.
Open the menu from its most common opener, a Dropdown Menu button, and walk the items with the arrow keys. Only the root and its trigger come from forty-cdk/dropdown-menu; every piece inside the surface is forty-cdk/menu.
Shared surface and item directives consumed by [forDropdownMenu] (button trigger) and [forContextMenu] (right-click), plus [forMenu], the opener-agnostic root for one menu definition driven by several openers at once. For a single opener, reach for one of the two presets; they are [forMenu] with the opener already chosen.
Anatomy
<div forMenuContent class="menu">
<div forMenuGroup>
<div forMenuGroupLabel>Appearance</div>
<button forMenuCheckboxItem [(checked)]="bold">
<span forMenuItemIndicator [forceMount]="true">✓</span>
Bold
</button>
</div>
<hr forMenuSeparator />
<div forMenuRadioGroup [(value)]="sortBy">
<div forMenuGroupLabel>Sort by</div>
<button forMenuRadioItem class="menu-radio-item" value="name">
<span forMenuItemIndicator [forceMount]="true">●</span>
Name
</button>
<button forMenuRadioItem class="menu-radio-item" value="date">Date modified</button>
</div>
<hr forMenuSeparator />
<button forMenuItem (activate)="save()">Save</button>
<div forMenuSub #sub="forMenuSub">
<button forMenuSubTrigger>More tools</button>
<!-- @if (sub.open()) { -->
<div forMenuSubContent>
<button forMenuItem>Developer tools</button>
</div>
<!-- } -->
</div>
</div>
For the recommended [forceMount] + opacity pattern that keeps indicator columns aligned across checkbox / radio items, see the selected-indicator alignment guide.
Examples
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ForDropdownMenu, ForDropdownMenuTrigger } from 'forty-cdk/dropdown-menu';
import {
ForMenuCheckboxItem,
ForMenuContent,
ForMenuGroup,
ForMenuGroupLabel,
ForMenuItem,
ForMenuItemIndicator,
ForMenuRadioGroup,
ForMenuRadioItem,
ForMenuSeparator,
ForMenuSub,
ForMenuSubTrigger,
} from 'forty-cdk/menu';
@Component({
selector: 'app-menu-default-example',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ForDropdownMenu,
ForDropdownMenuTrigger,
ForMenuContent,
ForMenuItem,
ForMenuCheckboxItem,
ForMenuRadioGroup,
ForMenuRadioItem,
ForMenuItemIndicator,
ForMenuSeparator,
ForMenuGroup,
ForMenuGroupLabel,
ForMenuSub,
ForMenuSubTrigger,
],
template: `
<div forDropdownMenu #menu="forDropdownMenu">
<button forDropdownMenuTrigger class="menu-trigger">View options</button>
@if (menu.open()) {
<div forMenuContent class="menu menu--wide" animate.enter="menu-pop-in">
<div forMenuGroup>
<div forMenuGroupLabel class="menu-label">Appearance</div>
<button
forMenuCheckboxItem
class="menu-item menu-item--check"
[(checked)]="showToolbar"
(activate)="$event.preventDefault()"
>
<span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
</span>
Show toolbar
</button>
<button
forMenuCheckboxItem
class="menu-item menu-item--check"
[(checked)]="showSidebar"
(activate)="$event.preventDefault()"
>
<span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
</span>
Show sidebar
</button>
</div>
<hr forMenuSeparator class="menu-separator" />
<div forMenuGroup>
<div forMenuGroupLabel class="menu-label">Sort by</div>
<div forMenuRadioGroup [(value)]="sortBy">
<button
forMenuRadioItem
value="name"
class="menu-item menu-item--check"
(activate)="$event.preventDefault()"
>
<span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
<svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
<circle cx="8" cy="8" r="8" fill="currentColor" />
</svg>
</span>
Name
</button>
<button
forMenuRadioItem
value="date"
class="menu-item menu-item--check"
(activate)="$event.preventDefault()"
>
<span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
<svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
<circle cx="8" cy="8" r="8" fill="currentColor" />
</svg>
</span>
Date modified
</button>
<button
forMenuRadioItem
value="size"
class="menu-item menu-item--check"
(activate)="$event.preventDefault()"
>
<span forMenuItemIndicator [forceMount]="true" class="menu-indicator">
<svg viewBox="0 0 16 16" width="7" height="7" aria-hidden="true">
<circle cx="8" cy="8" r="8" fill="currentColor" />
</svg>
</span>
Size
</button>
</div>
</div>
<hr forMenuSeparator class="menu-separator" />
<div forMenuSub #more="forMenuSub">
<button forMenuSubTrigger class="menu-item menu-item--check">
<span class="menu-indicator"></span>
More tools
<span class="menu-sub-arrow" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.75"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/>
</svg>
</span>
</button>
@if (more.open()) {
<div forMenuSubContent class="menu" animate.enter="menu-pop-in">
<button forMenuItem class="menu-item">Developer tools</button>
<button forMenuItem class="menu-item">Extensions</button>
<button forMenuItem class="menu-item">Task manager</button>
</div>
}
</div>
</div>
}
</div>
`,
})
export class MenuDefaultExample {
protected readonly showToolbar = signal(true);
protected readonly showSidebar = signal(false);
protected readonly sortBy = signal<string | null>('name');
}
forty-cdk/menu ships no opener of its own, so the surface is always opened through another entry point. In the example above, the opener is the button of Dropdown Menu. The same pieces compose unchanged under Context Menu and Menubar, whose pages carry the demos for those openers, and [forMenu] lets several of them open one surface.
Mount/visibility convention
[forMenuContent] follows the floating-overlay convention: the consumer's signal drives @if, the directive emits (close) (forwarded by the root primitive) when it wants to be unmounted. No [hidden]. See [forDropdownMenu] and [forContextMenu] for end-to-end examples.
Item activation contract
Every item type emits a vetoable (activate) event, whose handlers receive a VetoableEvent. The default action is to close the menu after the item's state has been applied (toggle for checkbox, set value for radio). Call event.preventDefault() on the veto to keep the menu open.
<!-- Closes menu by default -->
<button forMenuItem class="menu-item" (activate)="save()">Save</button>
<!-- Stays open -->
<button
forMenuCheckboxItem
class="menu-checkbox-item"
[(checked)]="bold"
(activate)="$event.preventDefault()"
>
Bold
</button>
API
ForMenu
Selector [forMenu], exportAs: 'forMenu'.
| Input | Type | Default | Notes |
|---|---|---|---|
open | model | false | Two-way. (openChange) fires on internal transitions only |
side | FloatingSide | undefined | 'bottom' | From provideForMenuDefaults; overridable per opener |
align | FloatingAlign | undefined | 'start' | From provideForMenuDefaults; overridable per opener |
sideOffset | number | 0 | From provideForMenuDefaults; overridable per opener |
alignOffset | number | 0 | Overridable per opener via [menuPositioning] |
avoidCollisions | boolean | true | |
fallbackAxisSideDirection | FloatingFallbackAxisSideDirection | 'none' | From provideForMenuDefaults |
collisionPadding | number | 8 | From provideForMenuDefaults |
sticky | 'partial' | 'always' | false | 'partial' | |
hideWhenDetached | boolean | false | |
clipUntilPositioned | boolean | true | |
loop | boolean | true | |
dir | 'ltr' | 'rtl' | null | null (ambient) | Reflected to the host dir attribute |
disabled | boolean | false | Blocks every opener |
dismissible | boolean | true | |
returnFocus | boolean | true | Returns focus to the active opener |
ariaLabel | string | null | null | Wins over the per-opener aria-labelledby fallback |
Outputs match the other trigger-anchored overlays: (escapeKeyDown), (pointerDownOutside), (focusOutside), (interactOutside), (autoFocusOnOpen), (autoFocusOnClose). All of them are vetoable via preventDefault().
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forMenu] | data-state | open | closed | |
[forMenu] | data-disabled | present | absent | |
[forMenuContent] / [forMenuSubContent] | data-state | open | closed | |
[forMenuItem] | data-disabled | present | absent | |
[forMenuItem] | data-highlighted | present | absent | |
[forMenuCheckboxItem] | data-state | checked | unchecked | |
[forMenuCheckboxItem] | data-disabled | present | absent | |
[forMenuCheckboxItem] | data-highlighted | present | absent | |
[forMenuRadioItem] | data-state | checked | unchecked | |
[forMenuRadioItem] | data-disabled | present | absent | |
[forMenuRadioItem] | data-highlighted | present | absent | |
[forMenuItemIndicator] | data-state | checked | unchecked | |
[forMenuSub] | data-state | open | closed | |
[forMenuSub] | data-disabled | present | absent | |
[forMenuSubTrigger] | data-state | open | closed | |
[forMenuSubTrigger] | data-disabled | present | absent | |
[forMenuSeparator] | data-orientation | horizontal | vertical |
Keyboard
ArrowDown / ArrowUp: move focus to the next / previous enabled item, wrapping by default.
Home / End: jump to first / last enabled item.
Enter / click: activate the focused item via native
<button>semantics. Closes the menu unless the consumer callsevent.preventDefault()on(activate).Space activates the focused item:
- On a plain
[forMenuItem], behaves like Enter / click (closes the menu). - On
[forMenuCheckboxItem]and[forMenuRadioItem], toggleschecked/ sets the groupvalue, emits(activate), and never closes the menu (per APG), so users can flip several options before dismissing. Callingevent.preventDefault()on(activate)is unnecessary for Space (the menu already stays open) but is still respected on Enter / click.
- On a plain
Tab / Shift+Tab: close the menu and return focus to the trigger. Inside a submenu, propagates upward and tears down the entire chain.
Escape: close the menu and return focus to the trigger. Inside a submenu, closes only that level (parent stays open).
ArrowRight (on a
[forMenuSubTrigger]): open the submenu and focus its first item. (LTR.)Enter / Space / click (on a
[forMenuSubTrigger]): open the submenu and focus its first item. On a submenu that is already open, for instance after a hover opened it, they move focus into it and never close it.ArrowLeft (on an item inside a submenu): close the submenu and return focus to the
[forMenuSubTrigger].Typeahead: single printable characters move focus to the first item whose text starts with the buffered string. Disabled items are skipped. By default the match is run against the item's accessible text, so an
aria-hiddensubtree (such as the[forMenuItemIndicator]glyph or a decorative icon) never bleeds into it, while visually-hidden but announced content still counts. PasstextValue="…"on[forMenuItem],[forMenuCheckboxItem], or[forMenuRadioItem]to override the matched string when announced text such as a kbd hint or a badge would otherwise bleed into it.<!-- Without textValue, prefix-match would compare against "3 Archive" --> <button forMenuItem class="menu-item" textValue="Archive"> <span class="badge">3</span> Archive </button>
Accessibility
Implements the WAI-ARIA Menu pattern for the surface (role="menu") and for items (menuitem / menuitemcheckbox / menuitemradio).
- Apply each item directive to a
<button>so Space / Enter activation come from native button behavior. - Disabled items keep
tabindex="-1"andaria-disabled="true"(never the nativedisabledattribute). They are skipped by arrow-key navigation, typeahead, Home/End, and pointer hover, and click / keyboard activation are no-ops, but they stay in the DOM so screen readers can still announce them. - The
role="menu"surface takes its accessible name from a consumer-set staticaria-labelledbyon[forMenuContent]when present (it is preserved, never clobbered), else from the root'sariaLabel(reflected asaria-label); with neither it falls back toaria-labelledbypointing at the trigger that opened it: the[forDropdownMenuTrigger]button, the[forMenubarTrigger], or the[forMenuSubTrigger]. The fallback is suppressed for a trigger that is not a discrete labelling control:[forContextMenu]'s right-click region is the whole row / card, so pointing the menu's name at it would announce that entire text, and[ariaLabel](or your ownaria-labelledby) is the way to name a context menu.[forMenu]decides per active opener rather than per root, so a shared menu names itself after the button that opened it and emits nothing when a region did. [forMenuSeparator]never registers with the menu's item collection, so it's skipped during navigation and typeahead automatically. It carriesrole="separator"and emitsaria-orientationonly fororientation="vertical", becausehorizontalis the ARIA default;data-orientationis always stamped for styling. Setdecorativewhen the surrounding items already convey the split. Setting it switches the line torole="none"and dropsaria-orientation, matching the shared separator emission policy.[forMenuGroup]is purely advisory grouping: items inside still register flatly with the parent menu, so navigation flows through groups without interruption.[forMenuGroup]and[forMenuRadioGroup]both exposerole="group"; give either an accessible name by projecting a[forMenuGroupLabel]inside it, which the group references viaaria-labelledby.[forMenuRadioGroup]'s[(value)]isstring | null, andnullis the "nothing selected" state, so bind asignal<string | null>(…). The empty string is a legitimate item value, so a[forMenuRadioItem] value=""(a "None" / "Any" option in a sort-order or filter menu) is only checked once the user picks it, exactly like any other value.- Submenus use
side="right"align="start"by default in LTR andside="left"align="start"in RTL. Set[dir]="'rtl'"on the top-level[forDropdownMenu]/[forContextMenu]and every nested[forMenuSub]inherits it (and flipsside, ArrowLeft/Right semantics, etc.). Override per-submenu with[dir]or[side]if a specific submenu needs to render against the opposite direction. - In RTL, ArrowLeft opens a submenu and ArrowRight closes it back to the parent. The swap mirrors the visual flip of the menu chain.
data-highlighted=""is reflected on the focused[forMenuItem]/[forMenuCheckboxItem]/[forMenuRadioItem]so consumers can paint a uniform focus ring shared with the listbox / select / combobox primitives. The attribute is intent-driven: opening a menu with the pointer focuses the first item without highlighting it (no "preselected" look on mouse open), while a keyboard open (Enter / Space / ArrowDown / ArrowUp on the trigger,Shift+F10for context menus) highlights the initially focused item. Arrow / Home / End / typeahead navigation always highlights the focused item.- Hover follows the pointer. Moving the mouse over an enabled item focuses and highlights it, so the keyboard highlight and the mouse hover never disagree. There is a single "active candidate" at a time. Hovering an adjacent item moves the highlight with it; hovering a disabled item is inert. When the pointer leaves the menu surface the highlight clears, while DOM focus stays anchored on the item so keyboard navigation continues from there. This means
[data-highlighted]is the only hover styling hook you need. You do not add a separate:hoverrule (it would fight the highlight). Touch / pen never hover, so this applies to mouse input only.
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).
[forMenuContent]/[forMenuSubContent]portal todocument.body, so a class scoped to your trigger's component cannot reach the surface. Style it with global CSS or a class you pass through (see Styling floating content). The content host also exposes the shared positioner custom properties, tabulated below and documented in full in Styling floating content:--for-floating-anchor-width/--for-floating-anchor-height,--for-floating-available-width/--for-floating-available-height, and--for-floating-content-transform-origin.
CSS custom properties
[forMenuContent] / [forMenuSubContent] are portaled to document.body and get their position resolved by floating-ui. The resolved geometry is exposed as custom properties on the content host (cleared on close). These also drive the content surface for [forDropdownMenu] and [forContextMenu], which reuse [forMenuContent]:
| Custom property | Type / range | Meaning | |
|---|---|---|---|
--for-floating-anchor-width | px | Anchor (trigger) width. Match it with width: var(--for-floating-anchor-width). | |
--for-floating-anchor-height | px | Anchor (trigger) height. | |
--for-floating-available-width | px | Space available along the inline axis (floating-ui size middleware). Clamp with max-width. | |
--for-floating-available-height | px | Space available along the block axis. Clamp with max-height. | |
--for-floating-content-transform-origin | <origin> keywords | transform-origin matching the resolved side / align, so a scale enter animation pivots from the trigger. |
.menu-item[data-highlighted],
.menu-checkbox-item[data-highlighted],
.menu-radio-item[data-highlighted] {
background: rgba(0, 0, 0, 0.06);
}
.menu-item[data-state='open'] .menu-sub-arrow {
transform: rotate(90deg);
}
Wrapping in a design system
Subclass the root and re-provide FOR_MENU_CONTEXT with useExisting pointing at the subclass, since Angular does not inherit a directive's providers; Wrapping non-form roots walks the pattern.