ContextMenu

Updated: Sep 24, 2026
ContextMenu presents a short, horizontal row of actions that apply to one selected object or region. It is transient popup content, not inline page content: you render it as the anchored, focusable content of a trigger such as Button or Container, and you keep the trigger mounted for as long as the menu is open.
The component owns the popup surface and drop shadow, the optional tail that points at the anchor, the horizontal scroller and its fading edges, arrow-key movement between items, initial focus, and the dismissal callback. You own the open state and what happens when an item is activated. When the popup closes while focus is still inside it, the anchored-popup infrastructure returns focus to the trigger. Restore focus explicitly after item activation or another close path that moves focus elsewhere first.

Usage

import { type MouseEvent, useLayoutEffect, useRef, useState } from 'react';
import {
  Button,
  type ButtonHandle,
  ButtonContextMenuItemView,
  ContextMenu,
  DismissReason,
  TooltipMode,
} from '@wearables-ui-toolkit/mrbd';

// bookmarkIcon, bellIcon, and archiveIcon are `IconSource` values you supply.

export function RouteActions() {
  const [isMenuOpen, setIsMenuOpen] = useState(false);
  const triggerRef = useRef<ButtonHandle>(null);
  const shouldRestoreFocus = useRef(false);

  const closeMenu = (event?: MouseEvent<HTMLElement>) => {
    // The popup is a React child of the trigger, so an item click bubbles to
    // the trigger's own handler. Stop it before it reopens the menu.
    event?.stopPropagation();
    shouldRestoreFocus.current = true;
    setIsMenuOpen(false);
  };

  const handleDismiss = (_reason: DismissReason) => {
    // The popup returns focus to the trigger itself when it closes with focus
    // still inside it, so no dismissal needs a manual restore here.
    setIsMenuOpen(false);
  };

  useLayoutEffect(() => {
    if (!isMenuOpen && shouldRestoreFocus.current) {
      shouldRestoreFocus.current = false;
      triggerRef.current?.getElement()?.focus({ preventScroll: true });
    }
  }, [isMenuOpen]);

  return (
    <Button
      ref={triggerRef}
      title="Route actions"
      alwaysShowText
      onClick={() => setIsMenuOpen(open => !open)}
      tooltipMode={isMenuOpen ? TooltipMode.FOCUSED : TooltipMode.NONE}
      tooltipFocusable
      tooltipHidesFocusState
      tooltipContentDescription="Route actions"
      tooltipContent={
        <ContextMenu aria-label="Route actions" onDismiss={handleDismiss}>
          <ButtonContextMenuItemView
            title="Save route"
            icon={bookmarkIcon}
            onClick={closeMenu}
          />
          <ButtonContextMenuItemView
            title="Mute alerts"
            icon={bellIcon}
            onClick={closeMenu}
          />
          <ButtonContextMenuItemView
            title="Archive"
            icon={archiveIcon}
            onClick={closeMenu}
          />
        </ContextMenu>
      }
    />
  );
}
Setting tooltipFocusable is what makes the menu reachable: without it the popup renders but never receives focus and does not install the transient Back behavior.

Opening on a preselected item

Take a ContextMenuHandle when the interesting item is not the first one, and scroll it into view once the popup is up.
import { useEffect, useRef } from 'react';
import {
  ContextMenu,
  type ContextMenuHandle,
  type DismissReason,
  EmojiContextMenuItemView,
} from '@wearables-ui-toolkit/mrbd';

interface Reaction {
  emoji: string;
  label: string;
}

export function ReactionMenu({
  reactions,
  selectedIndex,
  onSelect,
  onDismiss,
}: {
  reactions: readonly Reaction[];
  selectedIndex: number;
  onSelect: (reaction: Reaction) => void;
  onDismiss: (reason: DismissReason) => void;
}) {
  const menuRef = useRef<ContextMenuHandle>(null);

  useEffect(() => {
    menuRef.current?.scrollToItem(selectedIndex, false);
  }, [selectedIndex]);

  return (
    <ContextMenu ref={menuRef} aria-label="Reactions" onDismiss={onDismiss}>
      {reactions.map((reaction, index) => (
        <EmojiContextMenuItemView
          key={reaction.emoji}
          emoji={reaction.emoji}
          ariaLabel={reaction.label}
          isEmojiSelected={index === selectedIndex}
          onClick={() => onSelect(reaction)}
        />
      ))}
    </ContextMenu>
  );
}

Props

The full prop type is ContextMenuProps. It is declared as an extension of StaticContainerProps, but the menu owns its own material, shape, height, and background, and it forwards only the props listed here. Anything else the type happens to allow — other container props, arbitrary DOM attributes, extra event handlers — is accepted by TypeScript and dropped at runtime.

Own props

PropTypeDefaultDescription
children
ReactNode
—
Menu items. Use ContextMenuItemView or one of its two presets. Each direct child counts as exactly one item for arrow movement and for scrollToItem indices.
onDismiss
(reason: DismissReason) => void
—
Called when the menu asks to be dismissed. Clear your open state here.
showTail
boolean
true
Whether to draw the tail pointing at the anchor. The tail is only drawn when the anchoring system also supplies a direction; hiding it also reduces the gap to the anchor.
fadingEdgeLength
number
Medium fading-edge length
Length, in CSS pixels, of each horizontal fade. Set to 0 to disable both fades.
role
string
'menu'
Role for the popup root.
aria-label
string
—
Accessible name for the menu as a whole.
aria-disabled
boolean \| 'true' \| 'false'
—
Marks the whole menu as unavailable.
className
string
''
Extra class on the popup root, appended after the component’s own class.
style
CSSProperties
{}
Inline styles merged onto the popup root after the component’s own layout values.

Supplied by the anchoring system

These are filled in for you when the menu is used as anchored popup content. Do not set them by hand.
PropTypeDescription
tailDirection
'up' \| 'down'
Which way the tail points, chosen by the resolved placement.
tailCenterX
number
Horizontal centre of the tail, relative to the menu’s left edge.
maxWidth
number
Widest popup width the placement allows. Also caps the menu’s own max-width.
focusSearchOrigin
HTMLElement \| null
Anchor element used as the origin for a focus search when no menu item currently holds focus.

Types and enums

DismissReason

MemberValueMeaning
NAVIGATION
'navigation'
Up or Down was pressed, or focus left the menu entirely.
BACK_BUTTON
'back_button'
System Back, or Escape during desktop testing, was requested while the menu was focused.

Imperative handle

ContextMenuHandle, obtained with ref:
MethodSignatureDescription
scrollToItem
(index: number, smooth?: boolean) => void
Scrolls the item at index into view. smooth defaults to true; pass false to jump. An out-of-range index does nothing.
getElement
() => HTMLDivElement \| null
The popup root element, or null before mount and after unmount.

Behavior

  • Presentation. The menu is rendered by the trigger’s anchored-popup infrastructure into the floating portal root that App supplies. Do not portal it yourself or position it with fixed coordinates. Because it is focusable custom popup content, the placement may flip the menu to the other side of the trigger when the preferred side does not fit, and it will set tailDirection accordingly.
  • Sizing. Height is fixed by the component. Extra vertical room is reserved for the drop shadow, plus the tail on whichever edge it points from. Width is intrinsic — it grows with the items up to the width the placement allows.
  • Initial focus. Once the popup reports that it has been positioned, the first item is focused. The component keeps re-asserting that focus for a short settling period, so a trigger whose release animation briefly clears focus cannot strand the menu. If an item contains its own focusable element, that element is focused instead of the item wrapper.
  • Horizontal movement. Left and Right move focus between items and scroll the target clear of the fading edges. At the first or last item, focus stays put and the menu is not dismissed; a directional focus-search request is raised so the host can decide whether anything lies beyond the edge.
  • Vertical exit. Up and Down raise a focus-search request and then call onDismiss with NAVIGATION. There is no vertical movement inside the menu.
  • Escape and Back. On the device, system Back traverses the temporary, same-URL history entry owned by the anchored-popup infrastructure and dismisses the menu before the previous application route can be reached. In desktop testing, Escape calls onDismiss with BACK_BUTTON and follows the same dismissal path. Closing the menu another way removes the temporary entry. The popup infrastructure returns focus to the trigger whenever the menu closes with focus still inside it, so restore focus explicitly only after item activation or another path that moved focus first. Do not add another Back listener or history entry around the menu.
  • Focus loss. If focus ends up outside the menu for any other reason, the component calls onDismiss with NAVIGATION on the next tick. Focus moving between items inside the menu never triggers this.
  • Scrolling and fades. The item row is the single horizontal scroll container, and the browser scrollbar is hidden. A fade appears on whichever side still has content to scroll to, and its strength ramps with the distance left to scroll on that side, so a barely-overflowing row gets a barely-visible fade.
  • Materials. The surface is one drawn path — rounded ends plus the tail when there is one — filled with --uit-color-background-elevation3 and lifted by a drop shadow that tilts with the tail direction. Items draw no idle fill of their own, so they read as part of that surface until focused.

Accessibility

  • The popup root is a menu by default; each item is a menuitem.
  • Supply aria-label on the menu (and, on the trigger, tooltipContentDescription) so the popup is announced with a meaningful name. The component does not invent one.
  • Item names come from the items themselves. An icon-only item needs an explicit label — see ContextMenuItemView.
  • Decorative parts (the material shape and the two fading edges) are already marked aria-hidden, so nothing decorative is announced.
  • Keyboard testing covers the full interaction model: Left and Right traverse, Enter and Space activate, and Escape dismisses through the same path as system Back. There is no hover-only affordance to miss.
  • Keep the action set short, contextual, and ordered by priority.
  • Dismiss the menu before navigating when an item changes routes.
  • Restore focus to the trigger after activation; a BACK_BUTTON dismissal already performs that restoration.
  • Call event.stopPropagation() in item handlers when the trigger toggles the same open state.
  • Use a ContextMenuHandle and call scrollToItem when the relevant action is not first in the row.

Avoid

  • Present the menu as transient popup content rather than inline or permanently visible content.
  • Use a route for primary navigation; the menu is for contextual actions.
  • Complete or dismiss the current interaction before opening another overlay.
  • Only the documented props apply. Container props such as width, material, and backgroundStyle are dropped.
  • Pass each item as a direct child. A layout wrapper becomes one navigable item and collapses the row’s navigation model.
  • Keep the menu outside other horizontal scrollers and avoid nesting one inside it, which creates competing movement boundaries.
  • Popup internals and generated class names are not supported customization surfaces.