Opens at the pointer on right-click, long-press or Shift+F10.
Menus & tooltips@base-ui/react
01Preview
NameOffline
- q3-forecast.xlsx248 KB · Maya Chen
- brand-guidelines-v4-final-approved.pdf12.8 MB · Theo Okafor
- customer-interviews.md36 KB · You
Right-click a file, or focus it and press ⇧F10Press and hold a file for its actions
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react03Usage
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
<ContextMenu>
<ContextMenuTrigger tabIndex={0} className="rounded-lg data-popup-open:bg-hover">
q3-forecast.xlsx
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem icon={<Copy />} shortcut="⌘D" onClick={duplicate}>Duplicate</ContextMenuItem>
<ContextMenuItem icon={<Link />} shortcut="⇧⌘C" onClick={copyLink}>Copy link</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="danger" icon={<Trash />} onClick={trash}>Move to Trash</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>04Source
"use client";
import { ContextMenu as Base } from "@base-ui/react/context-menu";
import { useState } from "react";
import { cn } from "@/lib/cn";
import { DropdownMenuPopup, type DropdownMenuPopupProps } from "@/components/ui/dropdown-menu";
// Rows, groups, submenus and shortcuts are the dropdown menu's own parts: a context
// menu is the same list, opened from a different place.
export {
DropdownMenuItem as ContextMenuItem,
DropdownMenuLinkItem as ContextMenuLinkItem,
DropdownMenuCheckboxItem as ContextMenuCheckboxItem,
DropdownMenuRadioGroup as ContextMenuRadioGroup,
DropdownMenuRadioItem as ContextMenuRadioItem,
DropdownMenuGroup as ContextMenuGroup,
DropdownMenuLabel as ContextMenuLabel,
DropdownMenuSeparator as ContextMenuSeparator,
DropdownMenuSub as ContextMenuSub,
DropdownMenuSubTrigger as ContextMenuSubTrigger,
DropdownMenuSubContent as ContextMenuSubContent,
DropdownMenuShortcut as ContextMenuShortcut,
} from "@/components/ui/dropdown-menu";
export type {
DropdownMenuItemProps as ContextMenuItemProps,
DropdownMenuCheckboxItemProps as ContextMenuCheckboxItemProps,
DropdownMenuRadioItemProps as ContextMenuRadioItemProps,
DropdownMenuSubTriggerProps as ContextMenuSubTriggerProps,
} from "@/components/ui/dropdown-menu";
export type ContextMenuProps = Base.Root.Props;
/** Groups a region and the menu it opens on right-click or long-press. */
export function ContextMenu(props: ContextMenuProps) {
return <Base.Root {...props} />;
}
export type ContextMenuTriggerProps = Base.Trigger.Props & {
/** Open from the keyboard with Shift+F10 or the Menu key while the region has focus. */
keyboard?: boolean;
};
/**
* The region that owns the menu. On touch it sinks slightly while a long-press
* is registering, so the half-second hold never feels like nothing is happening.
*/
export function ContextMenuTrigger({ keyboard = true, className, onKeyDown, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, ...rest }: ContextMenuTriggerProps) {
const [pressing, setPressing] = useState<{ x: number; y: number } | null>(null);
return (
<Base.Trigger
data-pressing={pressing ? "" : undefined}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!keyboard || e.defaultPrevented || e.target !== e.currentTarget) return;
if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
e.preventDefault();
// Open where a pointer would have: near the start of the region, just under it.
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: r.left + Math.min(24, r.width / 2), clientY: r.bottom - 4 }),
);
}
}}
onTouchStart={(e) => {
onTouchStart?.(e);
const t = e.touches[0];
setPressing(e.touches.length === 1 && t ? { x: t.clientX, y: t.clientY } : null);
}}
onTouchMove={(e) => {
onTouchMove?.(e);
const t = e.touches[0];
// Same 10px tolerance Base UI uses before it gives up on the long-press.
if (pressing && t && Math.hypot(t.clientX - pressing.x, t.clientY - pressing.y) > 10) setPressing(null);
}}
onTouchEnd={(e) => {
onTouchEnd?.(e);
setPressing(null);
}}
onTouchCancel={(e) => {
onTouchCancel?.(e);
setPressing(null);
}}
className={(state) =>
cn(
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
"pointer-coarse:select-none",
"transition-[scale] duration-200 ease-out-expo",
"motion-safe:data-pressing:not-data-popup-open:scale-[0.985] data-pressing:not-data-popup-open:duration-500 data-pressing:not-data-popup-open:ease-out",
typeof className === "function" ? className(state) : className,
)
}
{...rest}
/>
);
}
export type ContextMenuContentProps = DropdownMenuPopupProps & {
collisionPadding?: Base.Positioner.Props["collisionPadding"];
/** Where the menu is portaled. Defaults to the body. */
container?: Base.Portal.Props["container"];
};
/**
* The menu, opened with its corner tucked under the pointer. It grows out of that
* exact point, and flips to stay on screen near the edges.
*/
export function ContextMenuContent({ collisionPadding = 8, container, ...rest }: ContextMenuContentProps) {
return (
<Base.Portal container={container}>
<Base.Positioner collisionPadding={collisionPadding} className="z-(--z-dropdown) outline-none">
<DropdownMenuPopup {...rest} />
</Base.Positioner>
</Base.Portal>
);
}05Props
ContextMenu
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state. Use with onOpenChange. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean, details) => void | — | Called when the menu opens or closes, with the reason. |
| disabled | boolean | false | Right-click falls through to the browser's own menu. |
ContextMenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
| keyboard | boolean | true | Shift+F10 or the Menu key opens the menu while the region itself has focus. Give it tabIndex={0}. |
| render | ReactElement | (props, state) => ReactElement | — | Render the region as your own element, such as an li or a table row. |
| className | string | (state) => string | — | Style the open state with data-popup-open to mark what the menu acts on. |
ContextMenuContent
| Prop | Type | Default | Description |
|---|---|---|---|
| collisionPadding | number | 8 | Space kept from the viewport edge when the menu flips. |
| container | HTMLElement | RefObject<HTMLElement> | — | Where the menu is portaled. Defaults to the body. |
ContextMenuItem · CheckboxItem · RadioGroup · RadioItem · Sub · SubTrigger · SubContent · Label · Separator · Group · Shortcut
| Prop | Type | Default | Description |
|---|---|---|---|
| — | Dropdown Menu parts | — | Re-exported from Dropdown Menu under Context Menu names, with the same props: icon, shortcut, description, variant. |
06Notes
Behavior
- The same rows render in a context menu and a dropdown, so the actions can live behind a visible ⋯ button too. A context menu should never be the only way to reach them.
- Right-press, drag and release on a row chooses it in one gesture; a plain right-click leaves the menu open.
- Long-press opens after 500ms and cancels if the finger moves more than 10px, so scrolling a list never opens it by accident.
- Near the viewport edges the menu flips to the other side of the pointer instead of being cut off.
Motion
- The menu grows out of the exact point that was pressed (scale 0.96, 180ms expo ease-out) with its corner tucked just under the pointer.
- On touch, the region sinks to 0.985 over the 500ms hold and springs back in 200ms as the menu opens, so a long-press is felt while it is registering.
- Rows share the dropdown's gliding highlight and drawn tick. Reduced motion keeps the fades and drops the sink, the growth and the glide.
Accessibility
- Base UI ContextMenu with the Menu roles; arrows, typeahead, Right and Left for submenus, Escape closes and returns focus to the region.
- Shift+F10 and the Menu key open it from a focused region, placed just under its start, which native context menus support and most web ones forget.
- Describe the shortcut on the region with aria-describedby so screen reader users learn it exists.