Stems UI
Menu

Context Menu

A menu that appears at the pointer on right click or long press.

Right click here

Last action: none · Bookmarks on · Person pedro

Codeapp/demos/public/context-menu.tsx
import { clientEntry, type Handle } from 'remix/component'
import {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
  onMenuSelect,
} from '@stemsui/registry/ui/context-menu.tsx'

export const ContextMenuDemo = clientEntry(
  import.meta.url,
  function ContextMenuDemo(handle: Handle) {
    let lastAction = 'none'
    let showBookmarks = true
    let showUrls = false
    let person = 'pedro'

    return () => (
      <div
        class="flex flex-col items-center gap-4"
        mix={onMenuSelect((event) => {
          if (event.kind === 'item') {
            lastAction = String(event.value ?? event.label)
            void handle.update()
          }
        })}
      >
        <ContextMenu>
          <ContextMenuTrigger
            tabIndex={0}
            data-testid="context-area"
            class="flex aspect-video w-full max-w-xs items-center justify-center rounded-xl border border-dashed text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
          >
            Right click here
          </ContextMenuTrigger>
          <ContextMenuContent class="w-52">
            <ContextMenuGroup>
              <ContextMenuItem value="back">
                Back
                <ContextMenuShortcut>⌘[</ContextMenuShortcut>
              </ContextMenuItem>
              <ContextMenuItem value="forward" disabled>
                Forward
                <ContextMenuShortcut>⌘]</ContextMenuShortcut>
              </ContextMenuItem>
              <ContextMenuItem value="reload">
                Reload
                <ContextMenuShortcut>⌘R</ContextMenuShortcut>
              </ContextMenuItem>
              <ContextMenuSub>
                <ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger>
                <ContextMenuSubContent class="w-44">
                  <ContextMenuItem value="save-page">Save Page...</ContextMenuItem>
                  <ContextMenuItem value="create-shortcut">Create Shortcut...</ContextMenuItem>
                  <ContextMenuItem value="name-window">Name Window...</ContextMenuItem>
                  <ContextMenuSeparator />
                  <ContextMenuItem value="developer-tools">Developer Tools</ContextMenuItem>
                  <ContextMenuSeparator />
                  <ContextMenuItem value="delete" variant="destructive">
                    Delete
                  </ContextMenuItem>
                </ContextMenuSubContent>
              </ContextMenuSub>
            </ContextMenuGroup>
            <ContextMenuSeparator />
            <ContextMenuGroup>
              <ContextMenuCheckboxItem
                checked={showBookmarks}
                onCheckedChange={(checked) => {
                  showBookmarks = checked
                  void handle.update()
                }}
              >
                Show Bookmarks
              </ContextMenuCheckboxItem>
              <ContextMenuCheckboxItem
                checked={showUrls}
                onCheckedChange={(checked) => {
                  showUrls = checked
                  void handle.update()
                }}
              >
                Show Full URLs
              </ContextMenuCheckboxItem>
            </ContextMenuGroup>
            <ContextMenuSeparator />
            <ContextMenuRadioGroup
              value={person}
              onValueChange={(value) => {
                person = String(value)
                void handle.update()
              }}
            >
              <ContextMenuLabel>People</ContextMenuLabel>
              <ContextMenuRadioItem value="pedro">Pedro Duarte</ContextMenuRadioItem>
              <ContextMenuRadioItem value="colm">Colm Tuite</ContextMenuRadioItem>
            </ContextMenuRadioGroup>
          </ContextMenuContent>
        </ContextMenu>

        <p class="text-sm text-muted-foreground" data-testid="context-menu-result">
          Last action: <span class="font-medium text-foreground">{lastAction}</span> · Bookmarks{' '}
          {showBookmarks ? 'on' : 'off'} · Person {person}
        </p>
      </div>
    )
  },
)

Installation

npx shadcn add @stemsui/context-menu

Requires the one-time setup. Behavior: stems-ui/context-menu.

API reference

The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.

stems-ui/context-menu

context-menu opens a menu at the pointer when an area is right-clicked or long-pressed. It is stems-ui/menu with a different root and trigger: every item, group, and submenu part is the same.

Base UI equivalent: Context Menu.

Usage

import * as contextMenu from 'stems-ui/context-menu'

function FileRow() {
  return () => (
    <contextMenu.Context>
      <div tabIndex={0} class="select-none" mix={contextMenu.trigger()}>
        report.pdf
      </div>
      <div class="rounded-md bg-white p-1 shadow-md" mix={contextMenu.popup()}>
        <div mix={contextMenu.item({ value: 'rename' })}>Rename</div>
        <div mix={contextMenu.item({ value: 'delete' })}>Delete</div>
      </div>
    </contextMenu.Context>
  )
}
Module split

Base UI exports ContextMenu.Root + ContextMenu.Trigger plus all Menu parts under the ContextMenu namespace, so Stems does the same: stems-ui/context-menu exports its own Context and trigger() and re-exports every stems-ui/menu part (popup, item, checkboxItem, RadioGroupContext, SubmenuContext, submenuTrigger, …). Mixing parts from either module works, since both share one implementation.

Differences from Base UI
  • Same as menu (no Portal / Positioner, item providers, no filtering).
  • Keyboard opening (Shift+F10, the ContextMenu key) is a Stems addition: the menu opens at the area's bottom-left corner with the first item focused.
  • modal defaults to false so a right click elsewhere in the area repositions the open menu. Opening a context menu closes any other open context menu.
  • A right click outside every trigger area doesn't dismiss the menu, because the dismiss layer only handles left-button presses. The browser's native menu opens on top instead.

stems-ui/context-menu

  • Context (Base UI ContextMenu.Root): same props as menu.Context (open / defaultOpen / onOpenChange, onOpenChangeComplete, loopFocus, orientation, disabled, highlightItemOnHover, onItemHighlighted), with modal defaulting to false.
  • trigger() (Base UI ContextMenu.Trigger): the area. No ARIA is added (make it focusable with tabIndex={0} for keyboard access).
    • contextmenu (right click): preventDefault(), opens at { clientX, clientY } with the popup focused. Right-clicking again while open repositions it.
    • Touch long press (500ms, cancelled by moving more than 10px or lifting): opens at the touch point. The touchend that follows is canceled so the compatibility click doesn't close it.
    • Shift+F10 / ContextMenu key: opens at the area's bottom-left corner, focusing the first item.
  • popup(options?): as in menu, but positioned against the pointer. Defaults to side: 'bottom', align: 'start'. The native context menu is suppressed inside the popup.
  • Every other part: see menu.
  • ContextMenuChangeEvent (stems:context-menu-change, onContextMenuChange(handler)): bubbles from the popup with open and reason ('trigger-press' when opened). MenuSelectEvent / onMenuSelect as in menu.

Behavior notes

  • Focus: when the menu closes, focus returns to the element that was focused when it opened (finalFocus overrides this).
  • Keyboard, typeahead, submenus, activation, and dismissal work as in menu.

Styling hooks

PartAttributeValues / notes
triggerdata-popup-open, data-pressedpresent while the menu is open
popup …see menudata-side / data-align vs. the point