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
Right-click or long-press a record. Nothing chosen yet.
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.
Examples
Groups and submenus
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
| Prop | Type | Default | Description |
|---|---|---|---|
| childrenrequired | ReactNode | – | The area the menu belongs to: a right click or a long press on it opens the menu there. |
| entriesrequired | readonly ContextMenuEntry[] | – | What the menu holds. |
| onSelectrequired | (id: string) => void | – | Called with the id of the command the crew choose, after the menu closes. |
| labelrequired | string | – | The menu's name for a screen reader ("Maintenance record actions"), translated by the caller. |
| disabled | boolean | undefined | false | Whether the area opens no menu, for a moment when the commands do not apply. |
| open | boolean | undefined | – | Whether the menu is open, for a caller that holds the state; pair it with onOpenChange. |
| defaultOpen | boolean | 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. |
| motion | boolean | undefined | true | Whether the menu fades and scales in from the pointer and out again; on by default. Under reduced motion it only fades, whatever this says. |
| className | string | undefined | – | Added to the area's classes. |
| testId | string | undefined | – | Becomes data-testid on the area; the menu is ${testId}--menu. |
| ref | Ref<HTMLDivElement> | undefined | – | The area element. |
Related
-
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.