Primitives
Dropdown Menu
A button that opens a menu of actions, with full keyboard navigation, typeahead and submenus.
Open the menu from the trigger and walk the items with the arrow keys. The highlighted item carries data-highlighted, and typing jumps to the first match.
New to overlays in forty-cdk? Your first overlay walks a Popover from empty markup to styled-and-animated and explains the
@if/ open-state model and the portal → global CSS rule.
When to choose
- Dropdown Menu: a
<button>that opens a menu of commands. Items run an action and close the surface; the menu carries no form value. - Context Menu: the same menu surface, opened by right-click, long-press,
Shift+F10or theContextMenukey and anchored at the pointer. Choose it when the actions belong to a region instead of to a visible control. - Menubar: a persistent bar of triggers with roving tabindex and cross-menu arrow navigation. Choose it when several menus sit together, as in a desktop application.
- Select: when the surface produces a value.
role="menu"announces commands;role="listbox"announces options.
Anatomy
<div forDropdownMenu #menu="forDropdownMenu" side="bottom" align="start">
<button forDropdownMenuTrigger class="dropdown-menu-trigger">
Options
<span class="chevron" aria-hidden="true"></span>
</button>
<!-- @if (menu.open()) { -->
<div forMenuContent>
<button forMenuItem (activate)="cut()">Cut</button>
<button forMenuItem (activate)="copy()">Copy</button>
<hr forMenuSeparator />
<div forMenuRadioGroup [(value)]="alignment">
<button forMenuRadioItem value="left">Left</button>
<button forMenuRadioItem value="center">Center</button>
</div>
</div>
<!-- } -->
</div>
The menu items, content surface, radio groups, separators, and groups come from the menu/ folder. [forContextMenu] uses the same primitives.
Examples
Checkbox & radio items
A settings-style dropdown built from the full menu vocabulary: forMenuGroup with a forMenuGroupLabel header, forMenuCheckboxItem toggles (role menuitemcheckbox) and a forMenuRadioGroup of forMenuRadioItem options (role menuitemradio). Each item carries a forMenuItemIndicator that paints its checkmark / dot from the item's checked state. Calling preventDefault() on (activate) keeps the menu open so several options can be flipped in one pass. Try Space to toggle without closing.
Submenus
forMenuSub nests a second menu under a forMenuSubTrigger item (role menuitem, aria-haspopup=menu). The submenu owns its own open model and item collection, and its forMenuSubContent reuses the menu surface positioned to the side of the trigger. Submenus nest arbitrarily. Here a third level sits inside the second. ArrowRight opens a submenu and focuses its first item; ArrowLeft collapses back to the parent; Escape closes one level at a time.
API
ForDropdownMenu
| Property | Type | Description |
|---|---|---|
open | model | Two-way bindable. Whether the menu is shown. Default: false |
side | input | Anchor side of [forMenuContent] against the trigger. The default is read from provideForDropdownMenuDefaults for the surrounding scope.Default: 'bottom' |
align | input | Alignment along side ('start' / 'center' / 'end'). The default is read from provideForDropdownMenuDefaults for the surrounding scope.Default: 'start' |
sideOffset | input | Gap (px) between the trigger and the content along the main axis. Default: 4 |
alignOffset | input | Gap (px) along the cross axis (parallel to side).Default: 0 |
fallbackAxisSideDirection | input | When both sides of the preferred axis overflow, lets flip drop the menu to a perpendicular side instead of clipping. 'none' keeps only the opposite same-axis placement. The default is read from provideForDropdownMenuDefaults for the surrounding scope, so set it once for the whole app rather than per call site.Default: 'none' |
loop | input | Whether arrow navigation wraps at the ends. Default: true |
dir | input | Writing direction. In RTL, ArrowLeft opens submenus and ArrowRight closes them. The swap is automatic. Inherited by every nested [forMenuSub] underneath unless overridden.Default: 'ltr' |
disabled | input | When true, trigger interactions are ignored.Default: false |
dismissible | input | When false, Escape and outside interactions don't close.Default: true |
returnFocus | input | When true, focus returns to the trigger on close.Default: true |
ariaLabel | input | Manual aria-label on [forMenuContent] if the trigger isn't a meaningful name.Default: null |
escapeKeyDown | output | Output. Escape pressed while the menu is the topmost dismissible layer. Default: — |
pointerDownOutside | output | Output. Pointer-down on a target outside content + trigger. Default: — |
focusOutside | output | Output. Focus moves outside content + trigger. Default: — |
interactOutside | output | Output. Composite: fires alongside the two above (and shares their veto state). Default: — |
autoFocusOnOpen | output | Output. Just before focus moves to the first / last enabled item on mount. Default: — |
autoFocusOnClose | output | Output. Just before focus returns to the trigger on unmount. Default: — |
Every output above is vetoable: each handler receives a VetoableEvent (or VetoableNativeEvent<E> when there is a native DOM event). Call preventDefault() on the emitted veto to suppress the directive's default action; the original DOM event, when present, is on .event.
(autoFocusOnOpen) / (autoFocusOnClose) are output-shape because DropdownMenu always routes close transitions through [(open)] (via the implicit openChange emitter). Dialog and Drawer take callback-shape inputs for the same pair instead: either can be closed by a direct open.set(false) that bypasses the (dismiss) output entirely, so their close hook has to be a stored function reference that still runs during teardown.
Data attributes
| Piece | Attribute | Values | |
|---|---|---|---|
[forDropdownMenu] | data-state | open | closed | |
[forDropdownMenu] | data-disabled | present | absent | |
[forDropdownMenuTrigger] | data-state | open | closed | |
[forDropdownMenuTrigger] | data-disabled | present | absent |
The menu items, content surface, radio groups, separators, and groups live in the menu/ folder. See menu → Styling for their data-state / data-highlighted / data-disabled attributes and the content-surface CSS custom properties.
Keyboard
| Key | Behavior | |
|---|---|---|
Click | Toggles the menu. On open, focus moves to the first enabled item. | |
Enter / Space / ArrowDown | Opens the menu and focuses the first enabled item; on an already-open menu, moves focus there. | |
ArrowUp | Opens the menu and focuses the last enabled item; on an already-open menu, moves focus there. |
The open keys never close the menu, because the APG menu-button pattern gives them no close semantics (that's Escape, or a pointer click on the trigger). Pressing one while the menu is already open moves focus into it, which is what makes them useful after an (autoFocusOnOpen)-vetoed open left focus on the trigger. A menu with no enabled item moves nothing.
Once focus is in the menu, see menu/README.md for the in-menu keyboard.
Accessibility
[forDropdownMenu] implements the WAI-ARIA Menu Button pattern. The trigger wires aria-haspopup="menu", aria-expanded, and aria-controls; the menu surface and item roles come from the shared menu/ primitives.
A disabled trigger (its own [disabled], or the root's) reflects through a single channel: the native disabled attribute plus the data-disabled styling hook. No aria-disabled is emitted, because the trigger is a real single-purpose <button> and the native attribute already conveys the state to assistive technology. Style the disabled trigger off [disabled] or [data-disabled], never [aria-disabled].
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).
The menu content (
[forMenuContent]) portals todocument.body, so a class scoped to your trigger's component cannot reach it. 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 (--for-floating-anchor-width/--for-floating-anchor-height,--for-floating-available-width/--for-floating-available-height, and--for-floating-content-transform-origin), documented in full in Styling floating content.
.dropdown-menu-trigger .chevron {
transition: transform 150ms;
}
.dropdown-menu-trigger[data-state='open'] .chevron {
transform: rotate(180deg);
}
Behavior notes
- Mount equals open. The directive does not toggle
[hidden]. Instead,@if (open())controls presence soanimate.enter/animate.leavefire on the natural mount cycle. - Trigger is exempt from outside-pointer / outside-focus checks. Without this, clicking the trigger to close would race with its own toggle handler and reopen immediately.
- Initial focus depends on the opening key. Click / Space / Enter / ArrowDown focus the first enabled item; ArrowUp focuses the last enabled item. The same keys re-focus that item when the menu is already open.
- Selecting an item closes the menu by default. To keep the menu open after activation (multi-select pattern), call
$event.preventDefault()in the item's(activate)handler.
#menu="forDropdownMenu" vs. [(open)]
The minimal "click trigger → show menu" case needs neither a separate open signal nor a two-way binding. [forDropdownMenu] is exportAs: 'forDropdownMenu', so expose the directive instance with a template reference variable (#menu="forDropdownMenu") and drive the @if straight off its own open() signal, as above. Trigger interactions, item activation, Escape, and outside dismissal all flip it.
Reach for the explicit [(open)]="mySignal" model binding only when the component class needs to read or drive open state (to open it programmatically, persist it, or react to it elsewhere):
<div forDropdownMenu [(open)]="open">
<button forDropdownMenuTrigger class="dropdown-menu-trigger">Options</button>
@if (open()) {
<div forMenuContent>…</div>
}
</div>
Triggers stamped from outside-declared templates
Angular resolves ng-template DI at the template's declaration site, not where it is stamped. A [forDropdownMenuTrigger] declared in a template outside the root throws the orphan error even when the template is rendered inside the root via ngTemplateOutlet. For that case the selector attribute accepts the root reference as a value, routerLink-style. Grab it with #root="forDropdownMenu" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<div forDropdownMenu #root="forDropdownMenu">
<ng-container *ngTemplateOutlet="trig; context: { root }" />
@if (root.open()) {
<div forMenuContent>…</div>
}
</div>
<ng-template #trig let-root="root">
<button [forDropdownMenuTrigger]="root">Options</button>
</ng-template>
Sharing one menu with a second opener
[forDropdownMenu] is a single-opener preset: one root, one button trigger. When the same actions must also be reachable another way (the canonical case being a table row with a kebab button and a right-click region over the whole row), bind the trigger to a [forMenu] root instead, which drives one [forMenuContent] block from any number of openers. See Shared openers.
<tr forMenu #row="forMenu" [(open)]="open" ariaLabel="Row actions">
<td [forContextMenuTrigger]="row">…cells…</td>
<td>
<button [forDropdownMenuTrigger]="row" [menuPositioning]="{ sideOffset: 4 }">⋮</button>
</td>
<!-- one content block, no duplication -->
</tr>
[menuPositioning] is the trigger's own placement override: a partial { side, align, sideOffset, alignOffset }, each key falling back to the root's input when omitted. It exists because a shared root cannot pick offsets that suit heterogeneous openers: the sideOffset: 4 above keeps the button-opened menu clear of the button while a sibling right-click region still opens flush at the cursor. Under a [forDropdownMenu] root it resolves the same way, where it is simply a per-trigger spelling of the root's inputs. See Per-opener positioning.
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.