Primitives
Context Menu
A menu opened by right-click or long-press, anchored to the pointer position.
Right-click the region (or focus it and press Shift+F10) and the menu opens at the pointer with data-state="open"; the arrow keys then walk the items.
Opened via the contextmenu event (right-click, long-press on touch) and via the keyboard activators Shift+F10 and the dedicated ContextMenu key. The native browser context menu is suppressed. Pointer activations anchor the menu at the cursor; keyboard activations anchor it at the bounding rect of the focused element so screen-reader / keyboard-only users get the menu next to whatever they're working on. Floating-ui's virtual element handles either case: placement, flip, and shift middleware still apply, so the menu is repositioned to stay on-screen automatically.
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
- Context Menu: actions for a region, opened by right-click, long-press,
Shift+F10or theContextMenukey and anchored at the pointer. It is a single-opener preset: one root, one region. - Dropdown Menu: the same menu surface opened from a visible
<button>. Choose it when the actions need a control the user can see and reach with Tab. - Menu: the opener-agnostic root, for when one menu definition must be driven by several openers at once (a right-click region and a kebab button).
- Menubar: a persistent bar of menus with arrow navigation between them.
Anatomy
<div forContextMenu #menu="forContextMenu">
<div forContextMenuTrigger tabindex="0">Right-click anywhere here</div>
<!-- @if (menu.open()) { -->
<div forMenuContent>
<button forMenuItem>Rename</button>
<button forMenuItem>Duplicate</button>
<button forMenuItem>Delete</button>
</div>
<!-- } -->
</div>
The menu items themselves come from the menu/ folder.
Examples
Rich content
The same menu vocabulary the Dropdown Menu exposes, anchored to the pointer on right-click: plain forMenuItem actions, a forMenuCheckboxItem toggle, a forMenuRadioGroup, a forMenuSub submenu, and grouped labels with separators. Checkbox and radio items call preventDefault() on (activate) to stay open; plain items close the menu and bubble up through any open submenu.
API
ForContextMenu
| Property | Type | Description |
|---|---|---|
open | model | Two-way bindable. Whether the menu is shown. Default: false |
side | input | Anchor side relative to the pointer. The default is read from provideForContextMenuDefaults for the surrounding scope.Default: 'bottom' |
align | input | Alignment along side ('start' / 'center' / 'end'). The default is read from provideForContextMenuDefaults for the surrounding scope.Default: 'start' |
sideOffset | input | Gap (px) between the pointer and the menu along the main axis. Default: 0 |
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 provideForContextMenuDefaults for the surrounding scope. Set it once for the whole app rather than per call site.Default: 'none' |
loop | input | Whether arrow navigation wraps. Default: true |
dir | input | Writing direction. In RTL the swap is automatic: ArrowLeft opens submenus and ArrowRight closes them. Inherited by every nested [forMenuSub] underneath unless overridden.Default: 'ltr' |
disabled | input | When true, the contextmenu event falls through to the native browser menu.Default: false |
dismissible | input | When false, Escape and outside interactions don't close.Default: true |
returnFocus | input | When true, focus returns on close to the trigger element, or to the focused element inside it that a keyboard activation started from.Default: true |
ariaLabel | input | Accessible name reflected as aria-label on [forMenuContent]. The root's only name hook for a context menu: the right-click region is never used as an aria-labelledby target.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. A composite that fires alongside the two above (and shares their veto state). Default: — |
autoFocusOnOpen | output | Output. Just before the imperative focus move on mount. Default: — |
autoFocusOnClose | output | Output. Just before the imperative focus move on unmount. Default: — |
Same vetoable dismiss API as DropdownMenu. 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 ContextMenu 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 | |
|---|---|---|---|
[forContextMenu] | data-state | open | closed | |
[forContextMenu] | data-disabled | present | absent | |
[forContextMenuTrigger] | data-state | open | closed | |
[forContextMenuTrigger] | data-disabled | present | absent |
Accessibility
[forContextMenu] implements the WAI-ARIA Menu pattern. The trigger captures contextmenu, Shift+F10, and the ContextMenu key; the menu surface and item roles come from the shared menu/ primitives. Pointer activations anchor at the cursor; keyboard activations anchor at the bounding rect of the focused element so keyboard-only users get the menu next to whatever they're working on.
- Name the menu with
[ariaLabel]. Unlike[forDropdownMenu]/[forMenubar]/[forMenuSub], the content surface emits noaria-labelledbyfallback here: the trigger is the whole right-click region, so pointing the menu's name at it would make screen readers announce the entire row / card text as the menu name. With no[ariaLabel]therole="menu"surface simply has no accessible name. A consumer-set staticaria-labelledbyon[forMenuContent]is still preserved, so pointing at your own visible heading also works. Submenus nested inside a context menu are unaffected: a[forMenuSubContent]is still labelled by its[forMenuSubTrigger].
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], from themenu/folder) portals todocument.body, so it sits outside the trigger's DOM subtree and descendant selectors won't reach it. Style it with global CSS or a class on the content element. 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,--for-floating-content-transform-origin); see Styling floating content for the full list and the animation rules.
.context-menu-trigger[data-state='open'] {
outline: 2px solid hotpink;
}
Behavior notes
- Trigger is NOT exempt from outside-pointer / outside-focus checks. Unlike DropdownMenu (where the trigger button toggles via its own click handler), the context-menu region is treated like any other "outside" element: a left-click on the region while the menu is open closes it. Right-clicking again immediately reopens at the new position. This is a full close → open cycle, not an in-place reposition: re-right-clicking while the menu is already open tears the surface down and rebuilds it (so any
animate.enter/animate.leavereplays and focus resets). Keep enter/leave animations cheap, or gate expensive ones, since a user can fire this rapidly. The directive deliberately exempts nothing, so there is no smooth reposition path to attach to. - Virtual anchor. Right-click captures a 0×0 rect at the pointer location.
Shift+F10andContextMenusnapshot the bounding rect of the focused element (or the trigger if focus is on it directly), so the menu floats off the element under attention. Closing a keyboard-opened menu returns focus to that same element, so a row in a roving-tabindex list keeps its position; when a menu action removed it, focus falls back to the region. Both forms feed floating-ui'sflipandshiftmiddleware, so corners and screen edges work without special-casing. - Keyboard activators only fire while focus is inside the trigger. Keyboard events dispatch to the focused element, so
Shift+F10/ContextMenuanywhere outside the trigger goes to the browser default. The trigger is focusable by default (host-boundtabindex="-1"), so this works out of the box and focus can return to it programmatically when the menu closes. Your owntabindexwins over that default: usetabindex="0"if you want the region itself reachable via Tab. - Native menu suppressed. The trigger calls
event.preventDefault()oncontextmenuand on the keyboard activators. Setdisabledto let the browser's native menu surface for that region. - An open that renders nothing closes again. When a right-click,
Shift+F10, theContextMenukey or a long-press opens a menu whose template renders no[forMenuContent], the trigger closes it after the next render with reason'programmatic', and dev mode logsFORCDK-CONTEXT-MENU-002once. The native menu cannot come back for that gesture, because it was already suppressed, so bind[disabled]to the same condition that renders the content:[disabled]="!hasActions(col)"beside@if (hasActions(col)). - Touch long-press. The trigger runs its own long-press timer (a
touchpointerdownheld ~500 ms, without lifting or moving past a small tolerance, opens the menu at the touch point). This is required because iOS Safari never fires thecontextmenuevent a long-press synthesizes elsewhere; where the browser does synthesize it (Android, desktop touch emulation) the two paths stay mutually exclusive, so the menu opens exactly once. For the press to survive on iOS, suppress the native callout / text-selection on the trigger with CSS, since otherwise the OS gesture cancels the press:
.context-menu-trigger {
-webkit-touch-callout: none;
user-select: none;
}
- Mount equals open. As in the rest of the library, wrap
[forMenuContent]in@if (open())and useanimate.enter/animate.leavefor transitions.
#menu="forContextMenu" vs. [(open)]
The minimal "right-click → show menu" case needs neither a separate open signal nor a two-way binding. [forContextMenu] is exportAs: 'forContextMenu', so expose the directive instance with a template reference variable (#menu="forContextMenu") and drive the @if straight off its own open() signal, as above. The contextmenu gesture, 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 forContextMenu [(open)]="open">
<div forContextMenuTrigger class="context-menu-trigger">Right-click anywhere here.</div>
@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 [forContextMenuTrigger] 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="forContextMenu" and pass it through the outlet context. The bare valueless attribute keeps resolving via DI.
<div forContextMenu #root="forContextMenu">
<ng-container *ngTemplateOutlet="chip; context: { root }" />
@if (root.open()) {
<div forMenuContent>…</div>
}
</div>
<ng-template #chip let-root="root">
<span [forContextMenuTrigger]="root">Right-click here</span>
</ng-template>
Sharing one menu with a second opener
[forContextMenu] is a single-opener preset: one root, one right-click region. When the same actions must also be reachable another way (the canonical case being a table row with a right-click region and a kebab button), bind the trigger to a [forMenu] root instead, which drives one [forMenuContent] block from any number of openers. See Shared openers.
The same explicit-reference input carries it, and here the binding is required rather than optional: the trigger resolves FOR_CONTEXT_MENU_CONTEXT, which [forMenu] deliberately does not provide (forty-cdk/menu must not depend on forty-cdk/context-menu).
<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>
Both triggers carry [menuPositioning], a partial { side, align, sideOffset, alignOffset } override applied only to the opens that trigger drives, with each omitted key falling back to the root's input. It exists because a shared root cannot pick offsets that suit heterogeneous openers: the region above keeps the root's sideOffset of 0 (flush at the cursor, which is what a pointer-anchored menu wants), while the sibling button opener asks for the 4px of clearance a menu button wants. Under a [forContextMenu] 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.