ContextMenu

A menu that opens at a point, for a right-click on something that is not a button: a row, a canvas, a bar in a schedule (also called a right-click menu). The entries and the keyboard are those of the menu.

import { ContextMenu, MenuItem, MenuSeparator } from "@umriss-ui/core";

It is controlled: on contextmenu you keep the point and open it there, and onOpenChange reports the wish to close. Focus goes to the first entry and back to where it was on closing.

A right-click is invisible to a keyboard. Open the same menu on Shift+F10 or the menu key at the focused element, and keep the actions reachable some other way too.

Examples

Open it at the pointer

On contextmenu, keep the event's point and pass it as position; ariaLabel names what the menu acts on.

import { useState } from "react";
import type { MouseEvent } from "react";
import { ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";

export default function AtThePointer() {
  const [at, setAt] = useState<{ x: number; y: number } | null>(null);
  const [last, setLast] = useState("-");

  const openHere = (event: MouseEvent) => {
    event.preventDefault();
    setAt({ x: event.clientX, y: event.clientY });
  };

  return (
    <Stack gap={3} align="flex-start">
      <div
        onContextMenu={openHere}
        style={{
          width: "100%",
          maxWidth: 320,
          height: 120,
          display: "grid",
          placeItems: "center",
          border: "1px dashed var(--u-edge-color-strong)",
          borderRadius: "var(--u-radius-md)",
        }}
      >
        <Text size="sm" tone="muted">
          Right-click anywhere in here
        </Text>
      </div>
      <ContextMenu
        open={at !== null}
        position={at ?? { x: 0, y: 0 }}
        onOpenChange={(open) => {
          if (!open) setAt(null);
        }}
        ariaLabel="Actions for the surface"
      >
        <MenuItem onSelect={() => setLast("Split")}>Split here</MenuItem>
        <MenuItem onSelect={() => setLast("Note added")}>Add a note</MenuItem>
        <MenuSeparator />
        <MenuItem tone="danger" onSelect={() => setLast("Removed")}>
          Remove
        </MenuItem>
      </ContextMenu>
      <Text size="xs" tone="muted">
        Last: {last}
      </Text>
    </Stack>
  );
}

Open it on a row, by pointer or keyboard

Right-click opens it at the pointer; Shift+F10 or the menu key opens it at the focused row, so no reader is left out.

import { useState } from "react";
import type { KeyboardEvent, MouseEvent } from "react";
import { ContextMenu, MenuItem, MenuSeparator, Stack, Text } from "@umriss-ui/core";

const SHIPMENTS = [
  { id: "SH-1042", customer: "Harlow Bakery", window: "09:00–11:00" },
  { id: "SH-1043", customer: "Pine & Oak Florist", window: "10:00–12:00" },
  { id: "SH-1044", customer: "Moreau Optics", window: "11:30–13:30" },
];

/* A focusable row owes a visible focus: the library's ring, from its token. */
const STYLE = `
.shipment-row:focus-visible {
  outline: none;
  box-shadow: var(--u-focus-ring);
}
`;

export default function OnARow() {
  const [menu, setMenu] = useState<{ id: string; x: number; y: number } | null>(null);
  const [last, setLast] = useState("-");

  const byPointer = (id: string) => (event: MouseEvent) => {
    event.preventDefault();
    setMenu({ id, x: event.clientX, y: event.clientY });
  };

  const byKey = (id: string) => (event: KeyboardEvent<HTMLElement>) => {
    if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) return;
    event.preventDefault();
    const box = event.currentTarget.getBoundingClientRect();
    setMenu({ id, x: box.left + 16, y: box.bottom });
  };

  const run = (what: string) => () => setLast(`${what} ${menu?.id ?? ""}`);

  return (
    <Stack gap={3} style={{ maxWidth: 480 }}>
      <style>{STYLE}</style>
      <div role="list" aria-label="Shipments">
        {SHIPMENTS.map((shipment) => (
          <div
            key={shipment.id}
            role="listitem"
            tabIndex={0}
            className="shipment-row"
            onContextMenu={byPointer(shipment.id)}
            onKeyDown={byKey(shipment.id)}
            style={{
              display: "flex",
              gap: "var(--u-space-3)",
              padding: "var(--u-space-2) var(--u-space-3)",
              borderBottom: "1px solid var(--u-edge-color)",
              background: menu?.id === shipment.id ? "var(--u-color-accent-subtle)" : undefined,
            }}
          >
            <Text as="span" size="sm" mono>
              {shipment.id}
            </Text>
            <Text as="span" size="sm" style={{ flex: 1 }}>
              {shipment.customer}
            </Text>
            <Text as="span" size="sm" tone="muted" mono>
              {shipment.window}
            </Text>
          </div>
        ))}
      </div>
      <ContextMenu
        open={menu !== null}
        position={menu ?? { x: 0, y: 0 }}
        onOpenChange={(open) => {
          if (!open) setMenu(null);
        }}
        ariaLabel={`Actions for shipment ${menu?.id ?? ""}`}
      >
        <MenuItem onSelect={run("Tracked")}>Track the shipment</MenuItem>
        <MenuItem onSelect={run("Rescheduled")}>Move to tomorrow</MenuItem>
        <MenuSeparator />
        <MenuItem tone="danger" onSelect={run("Returned")}>
          Return to sender
        </MenuItem>
      </ContextMenu>
      <Text size="xs" tone="muted">
        Last: {last}
      </Text>
    </Stack>
  );
}

When to use something else

Keyboard

Key Action
↓ / ↑ Moves to the next or previous entry, wrapping at the ends; disabled entries are skipped.
Home / End Moves to the first or last entry.
Escape Closes the menu and returns focus to where it was.
Enter or Space Runs the focused entry and closes the menu.
Shift+F10 or the menu key Opens the menu where you listen for them, as the row example does.

API

ContextMenuProps

NameTypeDefaultDescription
openrequiredboolean—Whether the menu stands. Controlled, like Popover: a right-click opens it only because the caller says so.

Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard

onOpenChangerequired(open: boolean) => void—Reports every wish to change the state – an entry chosen, Escape, an outside click.

Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard

positionrequired{ x: number; y: number }—Where the menu opens, in client coordinates – typically clientX and clientY of the event that asked for it. At a window edge the panel flips and clamps as every popover does.

Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard

childrenrequiredReactNode—The entries – MenuItem and MenuSeparator, as in Menu.

Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard

Accessibility

NameTypeDefaultDescription
ariaLabelrequiredstring—Accessible name of the menu: what it acts on. A context menu has no trigger whose label could name it.

Shown in: Open it at the pointer, Open it on a row, by pointer or keyboard

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index