Context menu
A right-click menu: a ContextMenu.Trigger area opens a floating list of actions at the pointer, replacing the browser's native context menu. It shares the Menu row family — items, groups, separators, submenus, and shortcuts — so a context menu reads identically to a dropdown.
Basic
A ContextMenu.Trigger marks the area that responds to a right-click (or long-press on touch); the ContextMenu.Content holds the rows. The trigger is focusable by default and ships visually neutral — style it yourself to signal that it's interactive.
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, FileText, Trash2 } from "lucide-react";
export default function ContextMenuBasic() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item icon={Copy}>Copy</ContextMenu.Item>
<ContextMenu.Item icon={FileText}>Rename</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item icon={Trash2} tone="danger">
Delete
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Acting on the right-clicked target
What makes a context menu contextual is that its actions operate on whatever was right-clicked. Give each target its own ContextMenu.Root and close over that target's data in the item handlers — here every file row opens a menu whose actions reference that file.
- Q3 report.pdf
- budget.xlsx
- notes.md
- logo.svg
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, FileText, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
const FILES = ["Q3 report.pdf", "budget.xlsx", "notes.md", "logo.svg"];
export default function ContextMenuOnARow() {
const [status, setStatus] = useState("Right-click a file to act on it.");
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<ul className="flex flex-col overflow-hidden rounded-md border border-border">
{FILES.map((file) => (
<ContextMenu.Root key={file}>
<ContextMenu.Trigger
render={
<li className="flex items-center gap-2 border-b border-border px-3 py-2 text-regular text-foreground last:border-b-0 hover:bg-surface-hover" />
}
>
<FileText className="size-4 text-muted" aria-hidden />
{file}
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item icon={Pencil} onClick={() => setStatus(`Renaming ${file}…`)}>
Rename
</ContextMenu.Item>
<ContextMenu.Item icon={Copy} onClick={() => setStatus(`Duplicated ${file}`)}>
Duplicate
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item icon={Trash2} tone="danger" onClick={() => setStatus(`Deleted ${file}`)}>
Delete
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
))}
</ul>
<output aria-live="polite" aria-atomic="true" className="text-small text-muted">
{status}
</output>
</div>
);
}
Groups
Wrap related items in a ContextMenu.Group with a ContextMenu.GroupLabel, divide sections with a ContextMenu.Separator, and add a trailing ContextMenu.Shortcut for keyboard hints. A destructive action takes tone="danger": a red label whose highlight tints toward danger.
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, Link2, Scissors, Trash2 } from "lucide-react";
export default function ContextMenuGroups() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Group>
<ContextMenu.GroupLabel>Edit</ContextMenu.GroupLabel>
<ContextMenu.Item icon={Scissors}>
Cut
<ContextMenu.Shortcut keys="Mod+X" />
</ContextMenu.Item>
<ContextMenu.Item icon={Copy}>
Copy
<ContextMenu.Shortcut keys="Mod+C" />
</ContextMenu.Item>
<ContextMenu.Item icon={Link2}>Copy link</ContextMenu.Item>
</ContextMenu.Group>
<ContextMenu.Separator />
<ContextMenu.Group>
<ContextMenu.GroupLabel>Danger zone</ContextMenu.GroupLabel>
<ContextMenu.Item icon={Trash2} tone="danger">
Delete
</ContextMenu.Item>
</ContextMenu.Group>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Inset alignment
Mixing iconed and icon-less rows leaves the labels ragged. Add inset to a row without a glyph to reserve the leading icon column, so every label lines up.
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, Scissors } from "lucide-react";
export default function ContextMenuInset() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item icon={Scissors}>Cut</ContextMenu.Item>
<ContextMenu.Item icon={Copy}>Copy</ContextMenu.Item>
{/* `inset` reserves the leading icon column so these icon-less rows line up with the iconed ones above. */}
<ContextMenu.Item inset>Paste</ContextMenu.Item>
<ContextMenu.Item inset>Paste without formatting</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item inset>Select all</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Checkbox & radio items
ContextMenu.CheckboxItem toggles a setting; ContextMenu.RadioGroup with ContextMenu.RadioItems selects exactly one value. Both keep the menu open when activated and reserve the leading column for their indicator, so labels stay aligned.
"use client";
import { ContextMenu } from "@stridge/noctis";
export default function ContextMenuCheckboxRadio() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.CheckboxItem defaultChecked>Show gridlines</ContextMenu.CheckboxItem>
<ContextMenu.CheckboxItem>Snap to grid</ContextMenu.CheckboxItem>
<ContextMenu.Separator />
<ContextMenu.RadioGroup defaultValue="medium">
<ContextMenu.RadioItem value="small">Small</ContextMenu.RadioItem>
<ContextMenu.RadioItem value="medium">Medium</ContextMenu.RadioItem>
<ContextMenu.RadioItem value="large">Large</ContextMenu.RadioItem>
</ContextMenu.RadioGroup>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Submenus
Nest a ContextMenu.SubmenuRoot wrapping a ContextMenu.SubmenuTrigger and its own ContextMenu.Content. The submenu opens beside its trigger on the inline-end side, flipping to inline-start at the viewport edge, and the trailing chevron mirrors under RTL. It opens on hover after a short intent delay and from the keyboard with Right arrow or Enter. Keep nesting to a single level for keyboard predictability.
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, Share2, Trash2 } from "lucide-react";
export default function ContextMenuSubmenu() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item icon={Copy}>Copy</ContextMenu.Item>
<ContextMenu.SubmenuRoot>
<ContextMenu.SubmenuTrigger icon={Share2}>Share</ContextMenu.SubmenuTrigger>
<ContextMenu.Content>
<ContextMenu.Item>Email</ContextMenu.Item>
<ContextMenu.Item>Messages</ContextMenu.Item>
<ContextMenu.Item>Copy link</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.SubmenuRoot>
<ContextMenu.Separator />
<ContextMenu.Item icon={Trash2} tone="danger">
Delete
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Link items
ContextMenu.LinkItem renders a real <a> for the canonical "Open in new tab" / "Open link" rows — it carries the same row recipe as Item (leading icon, inset, tone) and accepts anchor props (href, target, rel). Navigating is a terminal action, so a link closes the menu by default (closeOnClick={false} keeps it open).
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, ExternalLink, Link } from "lucide-react";
export default function ContextMenuLinkItems() {
return (
<ContextMenu.Root>
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-dashed border-border text-regular text-muted select-none">
Right-click here
</ContextMenu.Trigger>
<ContextMenu.Content>
{/* A LinkItem renders a real <a> — navigating is a terminal action, so it closes the menu. */}
<ContextMenu.LinkItem href="https://noctis.stridge.dev" target="_blank" rel="noreferrer" icon={ExternalLink}>
Open in new tab
</ContextMenu.LinkItem>
<ContextMenu.LinkItem href="https://noctis.stridge.dev" icon={Link}>
Open link
</ContextMenu.LinkItem>
<ContextMenu.Separator />
<ContextMenu.Item icon={Copy}>Copy link address</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
The trigger — focusable & armed
The trigger is a real, focusable part. It defaults to tabIndex={0} so it can be reached with Tab and opened from the keyboard, and context-menu.css gives it a :focus-visible ring. Base UI stamps data-pressed while a touch long-press registers (a neutral instant tint confirms the hold) and data-popup-open while the menu is open — a stable seam you can paint an "armed" state from. On touch, the long-press opens the Noctis menu instead of the OS callout or a text selection.
"use client";
import { ContextMenu } from "@stridge/noctis";
import { Copy, FileText } from "lucide-react";
export default function ContextMenuKeyboardAndTouch() {
return (
<ContextMenu.Root>
{/*
* The trigger is focusable by default, so it can be reached with Tab and opened with
* Shift+F10 / the Menu key. The arbitrary `data-popup-open:` variant tints it while its menu
* is open — proof the host has a stable "armed" seam. On touch, a long-press opens the menu.
*/}
<ContextMenu.Trigger className="grid h-32 place-items-center rounded-md border border-border text-regular text-muted transition-colors select-none hover:bg-surface-hover data-[popup-open]:border-ring data-[popup-open]:bg-surface-hover">
Focus me and press Shift+F10
</ContextMenu.Trigger>
<ContextMenu.Content>
{/* When the binding is live, mirror the Shortcut on `aria-keyshortcuts` so it reaches SR users. */}
<ContextMenu.Item icon={Copy} aria-keyshortcuts="Control+C">
Copy
<ContextMenu.Shortcut keys="Mod+C" />
</ContextMenu.Item>
<ContextMenu.Item icon={FileText} aria-keyshortcuts="Control+R">
Rename
<ContextMenu.Shortcut keys="Mod+R" />
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
);
}
Keyboard
A context menu is opened by pointer (right-click), by touch (long-press), or from the keyboard with Shift+F10 / the Menu key at the focused trigger; once open it is fully keyboard-operable.
| Key | Action |
|---|---|
| Shift + F10 / Menu | Open the menu at the focused trigger. |
| ↓ / ↑ | Move the highlight to the next / previous item. |
| Home / End | First / last item. |
| → | Open the highlighted submenu and move into it (← under RTL). |
| ← | Close the current submenu and return to its trigger (→ under RTL). |
| Enter / Space | Activate the highlighted item — run an Item, toggle a CheckboxItem, select a RadioItem, open a submenu. |
| Esc | Close the current menu level; on the root, return focus to the trigger area. |
| Tab | Close the whole menu and move focus on. |
| Characters | Typeahead — jump to the item whose label matches what you type. |
Disabled items stay reachable by the keyboard and are announced as disabled — they just can't be activated. That follows the ARIA menu pattern: removing them from navigation would hide why an action is unavailable from screen-reader users.
Accessibility
- Never the sole entry point. A right-click menu is discoverable only by trying it, so every action it offers must also be reachable another visible way — a toolbar button, a
Menu, a keyboard shortcut. Treat the context menu as an accelerator, not the only door to an action. - Keep the trigger focusable. The default
tabIndex={0}is what lets Shift+F10 open the menu; if you compose your ownrenderelement, make sure it's focusable too. An unfocusable trigger has no keyboard entry point. - Expose live shortcuts.
ContextMenu.Shortcutis decorative (hidden from assistive tech so the item's name stays clean). When the binding is actually live, also setaria-keyshortcutson the item — e.g.<ContextMenu.Item aria-keyshortcuts="Control+C">Copy<ContextMenu.Shortcut keys="Mod+C" /></ContextMenu.Item>— so the shortcut reaches screen-reader users.
Content conventions
A few conventions keep menus scannable and predictable:
- Verb + noun, in title case — "Copy link", "Move to trash", not a bare "Copy" where the object is ambiguous.
- An ellipsis (
…) ends a label that opens a follow-up dialog rather than acting immediately — "Rename…", "Move to…". - Group destructive actions at the bottom, behind a
Separator, and mark themtone="danger"so a slip doesn't land on them. - One nesting level. Deeper submenus are hard to reach with a pointer and unpredictable from the keyboard — flatten instead.
Modal & scrolling
A context menu is always modal — while it's open, page scroll is locked and outside content is inert. There is no non-modal variant: Base UI ignores modal on a context menu (it's always a "context-menu" parent), and passing it is a no-op that logs a warning.
The scroll lock reserves the scrollbar gutter (scrollbar-gutter: stable on <html>) as it hides the bar, so opening the menu doesn't shift the page. If the bar visually disappearing still bothers you, reserve the gutter for the whole app so the page looks identical with the menu open:
html {
scrollbar-gutter: stable both-edges;
}Anatomy
Compose a context menu from its parts. ContextMenu.Root owns the open state (it accepts every Base UI ContextMenu.Root prop — open, defaultOpen, onOpenChange).
ContextMenu.Root— owns the open state; renders no element of its own. Always modal — page scroll is locked and outside content is inert while open. UnlikeMenuthere is no non-modal variant: Base UI deliberately ignoresmodalon a context menu (it's always a"context-menu"parent), so passing it is a no-op that logs a warning. Useful passthrough knobs:disabled(suppress the menu on a region so the browser's native menu shows instead),onOpenChangeComplete(fires after the open/close transition settles), andcloseParentOnEsc(when nested, also close the parent on Escape).ContextMenu.Trigger— the area that opens the menu on right-click / long-press, suppressing the browser's native menu. Focusable by default (tabIndex={0}) so Shift+F10 works; style it directly or compose any element throughrender. Exposes adata-pressed/data-popup-openstate seam.ContextMenu.Content— the floating, elevated, animated surface holding the items. Props:side(defaultbottom;inline-endinside a submenu),align(defaultstart),sideOffset,alignOffset,collisionPadding.ContextMenu.Group+ContextMenu.GroupLabel— a labelled section of related items.ContextMenu.Item— a menu action with an optional leadingicon, aninsetalignment helper, and atone(default|danger).ContextMenu.LinkItem— a navigable row rendering an<a>(href,target,rel), with the sameicon/inset/tonerecipe asItem; closes the menu on click by default.ContextMenu.CheckboxItem— a toggling item with an animated check indicator.ContextMenu.RadioGroup+ContextMenu.RadioItem— a single-select set with a dot indicator.ContextMenu.SubmenuRoot+ContextMenu.SubmenuTrigger— a nested submenu; the trigger is an item-shaped row with a trailing, RTL-mirrored chevron.ContextMenu.Shortcut— a trailing, decorative keyboard hint composingKbd.ContextMenu.Separator— a hairline between groups of items.
The right-click-specific parts carry their own data-slot (context-menu-trigger, context-menu-content, context-menu-viewport); the shared row family reuses Menu's slots (menu-group, menu-group-label, menu-item, menu-link-item, menu-checkbox-item, menu-radio-group, menu-radio-item, menu-item-indicator, menu-submenu-trigger, menu-shortcut, menu-separator). Pair a slot with the Base UI state attributes (data-highlighted, data-disabled, data-checked) for host-side styling.
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam for the popup; set one on any ancestor and every context menu in that region retunes. The popup's min-width and viewport-padding are kept value-identical to the Menu overlay, so the two read the same — and the shared rows are tuned through the Menu tokens. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table; parts that only forward to Base UI list just the props they pass through.
ContextMenu.Root
No props of its own — forwards to the underlying Base UI part.
ContextMenu.Trigger
ContextMenu.Content
ContextMenu.Group
ContextMenu.GroupLabel
ContextMenu.Item
ContextMenu.LinkItem
ContextMenu.CheckboxItem
ContextMenu.RadioGroup
ContextMenu.RadioItem
ContextMenu.SubmenuRoot
No props of its own — forwards to the underlying Base UI part.