Skip to content
cennge-ui
GitHubv0.3.1, changelog

ContextMenu

A menu that opens at the pointer on right click, or on a long press on a touch screen, with the items of a Dropdown.

src/components/button

  • button.styles.ts
  • button.test.tsx
  • button.tsx
  • index.ts
  1. Right-click a file: the menu opens where the pointer is. On a touch screen, press and hold the file.
  2. The file stays marked while its menu is open; arrows, a typed letter and Enter work as in a Dropdown.
  3. Right-click another file while a menu is open: the menu moves to it.
Live: right-click a file, or press and hold it.

Field marks

Import
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem } from "cennge-ui";
Size
1.5 KB gzip for this import, its dependencies aside
Pattern
WAI-ARIA menu, opened by the contextmenu event
Keyboard
Arrow down Arrow up HomeEndEnter Esc
State
Uncontrolled; onOpenChange reports each change
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Touch
Opens on a long press of 700 ms
Turned off
isDisabled on the trigger leaves the browser's own menu
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: overlays

When to use#

A ContextMenu puts actions where the pointer already is: on a file, a row, a picture. It suits tools people use every day and expect to behave like desktop apps.

Treat it as a shortcut, never the only way to an action. Nothing on the page shows that a right click does something, and not everyone can right-click or long-press.

Similar#

  • Dropdown has the same items and opens from a button that everyone can see; give the actions of a ContextMenu a Dropdown as well.
  • Command finds any action in the app by typing; a ContextMenu offers the few that fit the thing under the pointer.

Right-click an area#

ContextMenuTrigger hands the right click, and the long press on touch screens, to the one element inside it. The items are the Dropdown's: shortcut hints, a disabled item, a destructive one in red.

screenshot-dark.png
basic.tsx
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, toast } from "cennge-ui";

export default function ScreenshotMenu() {
  return (
    <ContextMenu>
      <ContextMenuTrigger>
        <div className="grid aspect-video w-full max-w-80 place-items-center rounded-large border-2 border-dashed border-border text-sm text-muted select-none">
          screenshot-dark.png
        </div>
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem shortcut="F2" onAction={() => toast("Renaming screenshot-dark.png")}>
          Rename
        </ContextMenuItem>
        <ContextMenuItem onAction={() => toast("Set as the README image")}>Use in README</ContextMenuItem>
        <ContextMenuItem isDisabled>Compare with previous</ContextMenuItem>
        <ContextMenuSeparator />
        <ContextMenuItem color="danger" onAction={() => toast.danger("Deleted screenshot-dark.png")}>
          Delete
        </ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

The same actions from a button#

One list of actions can render both menus: a Dropdown on the row for everyone, and a ContextMenu around it for people who right-click.

payload-cache · main · 4f2c9e1
same-actions.tsx
import {
  Button,
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuTrigger,
  Dropdown,
  DropdownContent,
  DropdownItem,
  DropdownTrigger,
  toast,
} from "cennge-ui";

const deploy = "payload-cache · main · 4f2c9e1";

const actions = [
  { label: "Redeploy", run: () => toast("Redeploying 4f2c9e1") },
  { label: "View build log", run: () => toast("Opening the build log") },
  { label: "Roll back to this deploy", run: () => toast.warning("Rolled back to 4f2c9e1") },
];

export default function DeployRow() {
  return (
    <ContextMenu>
      <ContextMenuTrigger>
        <div className="flex w-full max-w-md items-center gap-3 rounded-medium bg-surface-3 py-2 pr-2 pl-4 text-sm">
          <span className="size-2 shrink-0 rounded-full bg-success" aria-hidden="true" />
          <span className="min-w-0 flex-1 truncate">{deploy}</span>
          <Dropdown>
            <DropdownTrigger>
              <Button size="sm" variant="light">
                Actions
              </Button>
            </DropdownTrigger>
            <DropdownContent align="end">
              {actions.map((action) => (
                <DropdownItem key={action.label} onAction={action.run}>
                  {action.label}
                </DropdownItem>
              ))}
            </DropdownContent>
          </Dropdown>
        </div>
      </ContextMenuTrigger>
      <ContextMenuContent>
        {actions.map((action) => (
          <ContextMenuItem key={action.label} onAction={action.run}>
            {action.label}
          </ContextMenuItem>
        ))}
      </ContextMenuContent>
    </ContextMenu>
  );
}

Let the browser's menu through#

With isDisabled the trigger stops taking the right click and the browser's own menu opens. Here that happens while part of the commit message is selected, so the browser's Copy and Search are there for it; with nothing selected, the right click opens the commit's actions.

4baadf3

fix(playground): read the repositories' update dates in UTC

browser-menu.tsx
import { useEffect, useRef, useState } from "react";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, toast } from "cennge-ui";

const commit = { hash: "4baadf3", message: "fix(playground): read the repositories' update dates in UTC" };

async function copyHash() {
  try {
    await navigator.clipboard.writeText(commit.hash);
    toast.success(`Copied ${commit.hash}`);
  } catch {
    toast.danger("Could not copy", { description: commit.hash });
  }
}

export default function CommitRow() {
  const message = useRef<HTMLParagraphElement>(null);
  const [hasSelection, setHasSelection] = useState(false);

  // Text selected in the message: right click opens the browser's menu, with Copy and Search.
  useEffect(() => {
    const update = () => {
      const selection = document.getSelection();
      setHasSelection(Boolean(selection && !selection.isCollapsed && message.current?.contains(selection.anchorNode)));
    };
    document.addEventListener("selectionchange", update);
    return () => document.removeEventListener("selectionchange", update);
  }, []);

  return (
    <ContextMenu>
      <ContextMenuTrigger isDisabled={hasSelection}>
        <div className="flex w-full max-w-md items-baseline gap-3 rounded-medium bg-surface-3 px-4 py-3 text-sm">
          <code className="shrink-0 font-mono text-primary-text">{commit.hash}</code>
          <p ref={message} className="min-w-0">
            {commit.message}
          </p>
        </div>
      </ContextMenuTrigger>
      <ContextMenuContent>
        <ContextMenuItem onAction={() => toast(`Checked out ${commit.hash}`)}>Check out</ContextMenuItem>
        <ContextMenuItem onAction={() => toast(`Reverting ${commit.hash}`)}>Revert</ContextMenuItem>
        <ContextMenuItem onAction={copyHash}>Copy hash</ContextMenuItem>
      </ContextMenuContent>
    </ContextMenu>
  );
}

Accessibility#

The trigger gets no role and no ARIA attribute, so nothing tells a screen reader that an area has a menu; that is why the same actions need a visible way in. Once open, the menu and its items have the roles menu and menuitem and the keyboard of a Dropdown, and Escape closes it. While it is open, the rest of the page is hidden from screen readers and ignores the pointer, unless isModal is false.

Styling#

While its menu is open, the trigger's element has data-state="open". Style it to keep the thing marked, as the files on the plate do with data-[state=open]:bg-primary-bg. The menu itself looks like a Dropdown's: at least 13rem wide, and scrolling when it is taller than the room in the window.

Props#

ContextMenu#

PropTypeDefault
onOpenChange
(isOpen: boolean) => void
–
isModal
booleanBlocks the rest of the page while open.
true
children
React.ReactNode
–

ContextMenuTrigger#

PropTypeDefault
childrenrequired
React.ReactElementThe area that reacts to right click. A single element that accepts a ref.
–
isDisabled
boolean
–

ContextMenuContent#

PropTypeDefault
container
HTMLElement | null
–

Also takes every attribute of <div>.

ContextMenuItem#

PropTypeDefault
color
"default""primary""secondary""success""warning""danger""info"Highlight colour. Use danger for destructive actions.
"default"
onAction
() => voidCalled when the item is chosen by click, Enter or Space.
–
description
React.ReactNodeSecond line under the label.
–
startContent
React.ReactNode
–
endContent
React.ReactNode
–
shortcut
React.ReactNodeKeyboard hint shown on the right, e.g. "⌘K".
–
isDisabled
boolean
–
keepOpen
booleanKeep the menu open after this item is chosen.
false