Skip to content

Context Menu

A menu that opens at the pointer, on right click or long press, over a region of the page rather than off a button. It is the same list of commands Menu renders — literally the same components below the trigger — anchored to where the user clicked instead of to a control.

context-menu/basic.tsx
Right click here

Import

import { ContextMenu } from "@forte-ui/react";

Anatomy

Only Root and Trigger are new. Everything below the trigger — the popup, every row kind, the groups, the separators, the submenus — is the Menu component: Base UI re-exports the Menu parts under its context-menu entry point, and so does this library, so ContextMenu.Item and Menu.Item are one function rather than two that look alike.

Three things follow from that, and they are the ones worth knowing:

  • The rows are themed with the --forte-menu-* knobs, not --forte-context-menu-*. The trigger is the only part with properties of its own.
  • The parts carry the Menu markers — data-forte="menu-popup", data-forte="menu-item" — so a consumer who re-skins menu rows from plain CSS re-skins both menus at once, which is almost always what they meant.
  • A list of rows can be handed to either popup without a wrapper deciding which namespace to render from.

Examples

Pair it with a visible menu

The context menu supplements a <Menu> button on the same card. Actions() is written once and dropped into both popups — the rows are the same components, so nothing has to branch on which menu is rendering them.

context-menu/with-menu.tsx
station-hofplein.jpg

Station Hofplein

JPG · 2.4 MB

One region per row

A single root around a whole list would open the same menu wherever the pointer was, and nothing would say which row it meant. Give each row its own <ContextMenu.Root> instead. render={<li />} keeps the trigger inside the list's content model — a bare <div> child of a <ul> is invalid HTML.

The ring on the region while its menu is open is doing real work here: the popup opens at the pointer and usually covers the row that produced it, so without the ring there is nothing left on screen tying the two together.

context-menu/rows.tsx
  • Q3 report.pdf
  • budget.xlsx
  • team offsite.md

Checkable rows

<ContextMenu.CheckboxItem> and <ContextMenu.RadioItem> behave exactly as they do in a dropdown, including the closeOnClick={false} default that lets a run of toggles be set in one visit.

context-menu/checkable.tsx
Canvas — right click

<ContextMenu.SubmenuRoot> is <Menu.SubmenuRoot>, and its popup still needs no positioning props. The nesting wins over the context menu's anchor-to-the-pointer placement, so a submenu opens sideways off its row exactly as it would in a dropdown.

context-menu/submenu.tsx
Right click here

Disabled

disabled on <ContextMenu.Root> turns the region off entirely — and the browser's own context menu comes back, because the trigger stops calling preventDefault on the event.

context-menu/disabled.tsx
Disabled region
Enabled, with a disabled row

Placement

There is nothing to place. <ContextMenu.Popup> reads the nesting from <ContextMenu.Root> and anchors itself to the point the pointer was at, with the offsets that land its corner on the cursor; it flips and shifts to stay inside the viewport from there. side, align, sideOffset and alignOffset are all still accepted and still override, but passing side in particular opts the popup out of pointer anchoring and back onto the anchor rectangle — which for a context menu is a zero-sized box at the click, so the result is a menu a few pixels away from where it should be.

Accessibility

Keyboard interactions
KeyBehaviour
Shift + F10 then Menu keyFires the platform's context-menu event on the focused element. If that element is inside a ContextMenu.Trigger the event bubbles up to it and this menu opens — so the region is keyboard-reachable exactly when it already contains something focusable, and not otherwise.
Arrow Down then Arrow UpOnce the menu is open, moves the highlight between rows.
Arrow RightOn a submenu trigger, opens the submenu and highlights its first row. Mirrored under RTL.
Arrow LeftInside a submenu, closes it and returns the highlight to the row that opened it. Mirrored under RTL.
Home then EndHighlights the first or the last row.
A–ZTypeahead: highlights the first row whose text matches what you type.
Enter then SpaceActivates the highlighted row.
EscCloses the menu. Inside a submenu it closes only that submenu, unless closeParentOnEsc is set.

<ContextMenu.Trigger> renders a plain <div> with no tabindex, no role and no ARIA, and none of that is an omission: a region that opens a menu on right click is not a button, and labelling it as one would promise a keyboard behaviour it does not have. It takes no tab stop of its own, so nothing lands on it and nothing announces it.

What it does get is the first row of the table. The context-menu key fires a real contextmenu event on whatever is focused, and that event bubbles — so a trigger wrapping a link, a button or any other focusable element is reachable from the keyboard through that element, with no extra work. A trigger wrapping inert content is not reachable at all. Once the menu is open it is an ordinary menu and behaves exactly like Menu, roving tabindex and all.

Theming

The trigger declares three properties, all of them about the ring that marks the region while its menu is open. Everything else on screen belongs to Menu — the popup surface, the rows, the separators, the motion — and is set through the --forte-menu-* knobs on the parts that declare them.

Like every knob in the library these are declared on the element that owns them, so an ancestor's value is only inherited and loses. Set them on the trigger itself through className or a style object; the popup is portalled to <body>, so an ancestor of the trigger cannot reach it in any case.

context-menu/theming.tsx
A louder ring
No ring at all
Theming tokens for ContextMenu
PropertyControlsDefault
--forte-context-menu-trigger-ring-colorColour of the ring that marks the region while its menu is openvar(--forte-color-primary)
--forte-context-menu-trigger-ring-widthWidth of that ring. Set it to 0 to leave the region unmarked2px
--forte-context-menu-trigger-ring-offsetHow far the ring sits outside the region's box; negative draws it insidecalc( -1 * var(--forte-context-menu-trigger-ring-width) )

API reference

ContextMenu.Root

Groups every part and owns the open state. It renders no DOM element, so it takes neither className nor ref, and it forwards Base UI's own ContextMenu.Root props unchanged — open, defaultOpen, onOpenChange, disabled, orientation, loopFocus, highlightItemOnHover, closeParentOnEsc, actionsRef and onOpenChangeComplete.

Four of Menu.Root's props are deliberately absent, because there is no trigger element to hang them on: modal (a context menu is always modal), openOnHover with its delay and closeDelay, and the handle / triggerId pair that lets a detached trigger drive a dropdown.

ContextMenu.Trigger

Props for ContextMenuTrigger
PropTypeDefaultDescription
childrenReactNodeThe region that answers a right click or a long press — a card, a table row, a canvas, a whole panel. It is ordinary content, not a control: the trigger adds no styling of its own to it beyond marking it while its menu is open.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
renderReactElement<unknown, string | JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, ContextMenuTriggerState>Replaces the rendered `<div>` with another element or component — `render={<tr />}` for a table row, `render={<li />}` inside a list, so the trigger does not break the parent's content model.

ContextMenu.Popup

Menu.Popup — the whole floating half of the anatomy (portal, optional backdrop, positioner and popup) as one part, with the placement defaults switched to pointer anchoring.

Props for MenuPopup
PropTypeDefaultDescription
alignAlignstartHow the popup aligns along the chosen side. Menus align to the trigger's start edge rather than its centre, which is Base UI's own default — a centred menu drifts left of a wide trigger and right of a narrow one, and the reading edge is what a list of commands should line up on.
alignOffsetnumber | OffsetFunction0; -4 inside a `Menu.SubmenuRoot`; 2 inside a `ContextMenu.Root`Extra offset in pixels along the alignment axis.
anchorElement | VirtualElement | RefObject<Element | null> | (() => Element | VirtualElement | null) | nullAn element to position the popup against instead of the trigger.
backdropbooleanfalseRender a dimming layer behind the popup. Off by default — a menu is a short-lived list, not a mode — but useful on small screens where the popup covers most of the page.
childrenReactNodeThe items, groups and separators to render.
classNamestringAdditional class name(s) for the popup surface. Applied after the internal styles so consumer utilities win without needing `!important`.
collisionPaddingPadding5Space to keep between the popup and the edge of its collision boundary.
containerHTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | nullRender the popup into a different container instead of `<body>`.
finalFocusboolean | RefObject<HTMLElement | null> | ((closeType: InteractionType) => boolean | void | HTMLElement | null)the triggerWhere focus goes when the menu closes. There is no `initialFocus` counterpart: Base UI moves focus into the popup on open, to the first item for a keyboard open and to the popup itself for a pointer one.
positionerClassNamestringAdditional class name(s) for the positioner — the absolutely positioned wrapper around the popup. This is the only way to reach `--forte-menu-z-index`, the one property declared there.
sideSide"bottom"; "inline-end" inside a `Menu.SubmenuRoot`; the popup is anchored to the pointer instead inside a `ContextMenu.Root`Which side of the trigger the popup opens on. Flips automatically to avoid collisions. `"inline-start"` / `"inline-end"` follow writing direction, but Base UI takes that direction from its own `DirectionProvider` context and not from the `dir` attribute — with no provider mounted it is `"ltr"`, so the two resolve to left and right whatever `dir` says. An RTL app has to mount `<DirectionProvider direction="rtl">` for them to mirror. The library's own CSS reads the attribute, so everything else here flips without it.
sideOffsetnumber | OffsetFunction4; -4 inside a `Menu.SubmenuRoot`; -5 inside a `ContextMenu.Root`Gap in pixels between the trigger and the popup.

The rest of the namespace

Item, LinkItem, CheckboxItem, RadioGroup, RadioItem, Group, GroupLabel, Separator, SubmenuRoot, SubmenuTrigger and Shortcut are the Menu parts, unchanged. Their props are documented on the Menu page, and their types are exported under both names — ContextMenuItemProps is an alias of MenuItemProps, not a second interface.