Skip to content
design
Components

Context menu

The menu of commands a right click or a long press opens on a row, an entry or an area, where it was pressed.

On this page
Impeller service · Due 14 Nov
Anode inspection · Due 02 Dec
Liferaft repack · Due 20 Mar

Right-click or long-press a record. Nothing chosen yet.

Maintenance records, each with a menu on a right click or a long press and an Open button beside it.
Code
import { Button } from "@bosunkit/components/button";
import { ContextMenu, type ContextMenuEntry } from "@bosunkit/components/context-menu";
import { Check, FolderInput, Pencil } from "lucide-react";
import { useState, type CSSProperties, type Dispatch, type ReactElement, type SetStateAction } from "react";

export const description: string =
  "A list of maintenance records. A right click or a long press on a record opens its commands where the crew pressed; " +
  "each record also has an Open button, so no command is reachable only through the menu.";

const RECORDS: readonly (readonly [string, string])[] = [
  ["Impeller service", "Due 14 Nov"],
  ["Anode inspection", "Due 02 Dec"],
  ["Liferaft repack", "Due 20 Mar"],
];

const ROW: CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  gap: "var(--space-md)",
  padding: "var(--space-sm) var(--space-md)",
  borderBottom: "1px solid var(--border)",
};

const ENTRIES: readonly ContextMenuEntry[] = [
  { id: "open", label: "Open record", icon: <Pencil size="1em" />, shortcut: "Enter" },
  { id: "complete", label: "Mark complete", icon: <Check size="1em" /> },
  { kind: "separator" },
  { id: "move", label: "Move to another system", icon: <FolderInput size="1em" />, disabled: true },
];

export default function Showcase(): ReactElement {
  const [last, setLast]: [string, Dispatch<SetStateAction<string>>] = useState<string>("Nothing chosen yet");
  return (
    <div style={{ width: "100%", maxWidth: 480 }}>
      {RECORDS.map(([title, due]: readonly [string, string]): ReactElement => (
        <ContextMenu
          key={title}
          entries={ENTRIES}
          label={`${title} actions`}
          onSelect={(id: string): void => {
            setLast(`${title}: ${id}`);
          }}
        >
          <div style={ROW}>
            <span>
              {title} <span style={{ color: "var(--text-dim)" }}>· {due}</span>
            </span>
            <Button
              size="sm"
              variant="ghost"
              onClick={(): void => {
                setLast(`${title}: open`);
              }}
            >
              Open
            </Button>
          </div>
        </ContextMenu>
      ))}
      <p style={{ marginBlock: "var(--space-md) 0", color: "var(--text-dim)", fontSize: "0.85em" }}>
        Right-click or long-press a record. {last}.
      </p>
    </div>
  );
}

A context menu wraps an area and opens a menu of commands at the point the crew press: a right click with a mouse, a long press with a finger. It is built on Base UI's context menu, so the arrow keys, Home, End and the first letters of a command move through it, Enter chooses, Escape closes and returns focus to the area, and the right arrow opens a submenu. The menu fades and scales in from the point it opened at, and only fades under reduced motion.

The entries are data: a command with an id and a label, a separator, a named group, or a submenu. The caller translates every label, and onSelect receives the id of the command chosen after the menu closes. An unavailable command stays in the menu, dimmed and announced as disabled, so the crew can see it exists.

01A shortcut, never the only wayA long press is not discoverable and a keyboard or switch user never meets it, so every command also has a visible control on the page.
02Opens where it was pressedThe menu appears under the finger or the pointer, so the crew do not look away from the row they chose.
03Touch-sized when touchedUnder a coarse pointer every command is a 48 px target, and the menu keeps clear of the screen edge.

Examples

Groups and submenus

21:04 · Wind NW 14 kn, 1012 hPa, all well. Right-click or long-press this entry.
A ship log entry's menu with a named group and a submenu: the right arrow opens the submenu, Left or Escape closes it.
Code
import { ContextMenu, type ContextMenuEntry } from "@bosunkit/components/context-menu";
import type { ReactElement } from "react";

export const description: string =
  "A ship log entry's menu with a named group and a submenu: the right arrow opens the submenu, Left or Escape closes it.";

const ENTRIES: readonly ContextMenuEntry[] = [
  { id: "edit", label: "Edit entry" },
  {
    kind: "group",
    id: "mark",
    label: "Mark as",
    entries: [
      { id: "watch-change", label: "Watch change" },
      { id: "position-fix", label: "Position fix" },
    ],
  },
  { kind: "separator" },
  {
    kind: "submenu",
    id: "copy",
    label: "Copy as",
    entries: [
      { id: "copy-text", label: "Text" },
      { id: "copy-csv", label: "CSV row" },
    ],
  },
];

export default function GroupsAndSubmenus(): ReactElement {
  return (
    <ContextMenu entries={ENTRIES} label="Log entry actions" onSelect={(): void => undefined}>
      <div
        style={{
          width: "100%",
          maxWidth: 420,
          padding: "var(--space-md)",
          border: "1px solid var(--border)",
          borderRadius: "var(--radius-md)",
          fontFamily: "var(--font-mono)",
        }}
      >
        21:04 · Wind NW 14 kn, 1012 hPa, all well. Right-click or long-press this entry.
      </div>
    </ContextMenu>
  );
}

Keep commands discoverable

Provide a visible route to essential commands through a button, toolbar or labeled menu trigger. A right click or long press is an additional path, not the only way to discover a task. Make the target area keyboard reachable and check its context-menu key behavior in the consuming screen.

Keep command labels specific to the item being acted on. Preserve disabled commands when their presence helps explain availability, with a visible reason where needed. Route destructive commands through the same confirmation and save handling as their visible counterparts; opening this menu does not grant extra authority.

Props

PropTypeDefaultDescription
childrenrequiredReactNode–The area the menu belongs to: a right click or a long press on it opens the menu there.
entriesrequiredreadonly ContextMenuEntry[]–What the menu holds.
onSelectrequired(id: string) => void–Called with the id of the command the crew choose, after the menu closes.
labelrequiredstring–The menu's name for a screen reader ("Maintenance record actions"), translated by the caller.
disabledboolean | undefinedfalseWhether the area opens no menu, for a moment when the commands do not apply.
openboolean | undefined–Whether the menu is open, for a caller that holds the state; pair it with onOpenChange.
defaultOpenboolean | undefined–Whether the menu starts open, when the caller does not hold the state.
onOpenChange((open: boolean) => void) | undefined–Called with the open state a press, Escape or a choice asks for.
motionboolean | undefinedtrueWhether the menu fades and scales in from the pointer and out again; on by default. Under reduced motion it only fades, whatever this says.
classNamestring | undefined–Added to the area's classes.
testIdstring | undefined–Becomes data-testid on the area; the menu is ${testId}--menu.
refRef<HTMLDivElement> | undefined–The area element.
Do
Impeller service
Put every command in the menu on the page too: here Open sits beside the record as a button.
Don't
Impeller service (right-click for commands)
Don't make the menu the only way to reach a command: a long press is not discoverable, and a keyboard never opens it.
  • Action menu: discoverable button-triggered commands and persistent choices.

  • Popover: the panel that opens from a visible control and stays open.

  • Select: a list of choices that opens from a button, for a value rather than a command.

  • Button: the visible control a command in the menu also has.

On a touch screen the area also shows a ⋯ button at its end, named by the menu, that opens the same commands: a long press is not discoverable, and the button is. Under a fine pointer it is not drawn, and a right click opens the menu.