Context Menu
A menu opened by right-clicking a region of the page.
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/lib/components/ui/context-menu";
export default function ContextMenuDemo() {
return ContextMenu(
ContextMenuTrigger(
{
class:
"flex h-36 w-full max-w-sm items-center justify-center rounded-md border border-dashed text-sm select-none",
},
"Right click here",
),
ContextMenuContent(
{ class: "w-52" },
ContextMenuItem({ onSelect: () => console.log("back") }, "Back"),
ContextMenuItem({ disabled: true }, "Forward"),
ContextMenuItem({ onSelect: () => console.log("reload") }, "Reload"),
ContextMenuSeparator(),
ContextMenuSub(
ContextMenuSubTrigger("Share"),
ContextMenuSubContent(
{ class: "w-44" },
ContextMenuItem({ onSelect: () => console.log("share-email") }, "Email"),
ContextMenuItem({ onSelect: () => console.log("share-message") }, "Message"),
ContextMenuItem({ onSelect: () => console.log("share-link") }, "Copy link"),
),
),
ContextMenuSeparator(),
ContextMenuItem({ onSelect: () => console.log("save") }, "Save page as…"),
ContextMenuItem({ onSelect: () => console.log("print") }, "Print…"),
ContextMenuSeparator(),
ContextMenuItem({ onSelect: () => console.log("inspect") }, "Inspect"),
),
);
}Installation
npx jsrepo add @implementjs/ui/context-menu
It installs @implementjs/lucide at the same time.
Copy the file below to src/lib/components/ui/context-menu.ts. It imports cn, so copy utils.ts to src/lib/utils.ts too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Span, type Child, type ComponentProps } from "@implementjs/core";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "@implementjs/lucide";
import {
ContextMenu as ContextMenuPrimitive,
ContextMenuCheckboxGroup as ContextMenuCheckboxGroupPrimitive,
ContextMenuCheckboxItem as ContextMenuCheckboxItemPrimitive,
ContextMenuContent as ContextMenuContentPrimitive,
ContextMenuGroup as ContextMenuGroupPrimitive,
ContextMenuGroupHeading as ContextMenuGroupHeadingPrimitive,
ContextMenuItem as ContextMenuItemPrimitive,
ContextMenuRadioGroup as ContextMenuRadioGroupPrimitive,
ContextMenuRadioItem as ContextMenuRadioItemPrimitive,
ContextMenuSeparator as ContextMenuSeparatorPrimitive,
ContextMenuSub as ContextMenuSubPrimitive,
ContextMenuSubContent as ContextMenuSubContentPrimitive,
ContextMenuSubTrigger as ContextMenuSubTriggerPrimitive,
ContextMenuTrigger as ContextMenuTriggerPrimitive,
} from "@implementjs/primitives";
import { createComponent } from "@implementjs/primitives";
import { cn } from "@/lib/utils";
export type ContextMenuProps = ComponentProps<typeof ContextMenuPrimitive>;
export const ContextMenu = ContextMenuPrimitive;
export type ContextMenuTriggerProps = ComponentProps<typeof ContextMenuTriggerPrimitive>;
export const ContextMenuTrigger = ContextMenuTriggerPrimitive;
export type ContextMenuContentProps = ComponentProps<typeof ContextMenuContentPrimitive>;
export const ContextMenuContent = createComponent(function ContextMenuContent(
{ class: className, ...props }: ContextMenuContentProps,
...children: Child[]
) {
return ContextMenuContentPrimitive(
{
...props,
"data-slot": "context-menu-content",
// no overflow clipping: sub-content panels render nested inside and extend past this box
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"origin-(--ip-context-menu-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});
export type ContextMenuItemProps = ComponentProps<typeof ContextMenuItemPrimitive>;
export const ContextMenuItem = createComponent(function ContextMenuItem(
{ class: className, ...props }: ContextMenuItemProps,
...children: Child[]
) {
return ContextMenuItemPrimitive(
{
...props,
"data-slot": "context-menu-item",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
);
});
export type ContextMenuCheckboxGroupProps = ComponentProps<
typeof ContextMenuCheckboxGroupPrimitive
>;
export const ContextMenuCheckboxGroup = ContextMenuCheckboxGroupPrimitive;
export type ContextMenuCheckboxItemProps = ComponentProps<
typeof ContextMenuCheckboxItemPrimitive
> & {
/**
* The checked indicator, drawn in place of the default check. The left
* padding the default one is absolutely positioned into comes off with it:
* a custom indicator sits in the row's flow, so placing it is yours.
*/
indicator?: Child;
};
export const ContextMenuCheckboxItem = createComponent(function ContextMenuCheckboxItem(
{ class: className, indicator, ...props }: ContextMenuCheckboxItemProps,
...children: Child[]
) {
return ContextMenuCheckboxItemPrimitive(
{
...props,
"data-slot": "context-menu-checkbox-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// room in the gutter for the default check, which is positioned into it
indicator === undefined && "py-1.5 pr-2 pl-8",
className,
),
},
indicator ??
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CheckIcon({
"aria-hidden": true,
class: "size-4 hidden group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type ContextMenuRadioGroupProps = ComponentProps<typeof ContextMenuRadioGroupPrimitive>;
export const ContextMenuRadioGroup = ContextMenuRadioGroupPrimitive;
export type ContextMenuRadioItemProps = ComponentProps<typeof ContextMenuRadioItemPrimitive>;
export const ContextMenuRadioItem = createComponent(function ContextMenuRadioItem(
{ class: className, ...props }: ContextMenuRadioItemProps,
...children: Child[]
) {
return ContextMenuRadioItemPrimitive(
{
...props,
"data-slot": "context-menu-radio-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CircleIcon({
"aria-hidden": true,
class: "size-2 hidden fill-current group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type ContextMenuGroupProps = ComponentProps<typeof ContextMenuGroupPrimitive>;
export const ContextMenuGroup = ContextMenuGroupPrimitive;
export type ContextMenuGroupHeadingProps = ComponentProps<typeof ContextMenuGroupHeadingPrimitive>;
export const ContextMenuGroupHeading = createComponent(function ContextMenuGroupHeading(
{ class: className, ...props }: ContextMenuGroupHeadingProps,
...children: Child[]
) {
return ContextMenuGroupHeadingPrimitive(
{
...props,
"data-slot": "context-menu-group-heading",
class: cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className),
},
...children,
);
});
export type ContextMenuSeparatorProps = ComponentProps<typeof ContextMenuSeparatorPrimitive>;
export const ContextMenuSeparator = createComponent(function ContextMenuSeparator({
class: className,
...props
}: ContextMenuSeparatorProps) {
return ContextMenuSeparatorPrimitive({
...props,
"data-slot": "context-menu-separator",
class: cn("-mx-1 my-1 h-px bg-border", className),
});
});
export type ContextMenuSubProps = ComponentProps<typeof ContextMenuSubPrimitive>;
export const ContextMenuSub = ContextMenuSubPrimitive;
export type ContextMenuSubTriggerProps = ComponentProps<typeof ContextMenuSubTriggerPrimitive>;
export const ContextMenuSubTrigger = createComponent(function ContextMenuSubTrigger(
{ class: className, ...props }: ContextMenuSubTriggerProps,
...children: Child[]
) {
return ContextMenuSubTriggerPrimitive(
{
...props,
"data-slot": "context-menu-sub-trigger",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
ChevronRightIcon({ "aria-hidden": true, class: "ml-auto size-4" }),
);
});
export type ContextMenuSubContentProps = ComponentProps<typeof ContextMenuSubContentPrimitive>;
export const ContextMenuSubContent = createComponent(function ContextMenuSubContent(
{ class: className, offset = 8, ...props }: ContextMenuSubContentProps,
...children: Child[]
) {
return ContextMenuSubContentPrimitive(
{
offset,
...props,
"data-slot": "context-menu-sub-content",
// submenus pop in without a transition, so they feel instant
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-[state=open]:block",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden",
className,
),
},
...children,
);
});Usage
The same menu as the dropdown, anchored to the pointer instead of a trigger button. ContextMenuTrigger is the region you right-click, and it is passed straight through from the primitive — it has no styling of its own, so the region is yours to define.
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/lib/components/ui/context-menu";
ContextMenu(
ContextMenuTrigger({ class: "rounded-md border border-dashed p-8" }, "Right click here"),
ContextMenuContent(
{ class: "w-52" },
ContextMenuItem({ onSelect: back }, "Back"),
ContextMenuItem({ disabled: true }, "Forward"),
ContextMenuSeparator(),
ContextMenuItem({ onSelect: inspect }, "Inspect"),
),
);
Styled like the dropdown menu
The panels, items, separators, and indicators are drawn to match the dropdown menu, but the classes live in this file — it installs on its own, and editing it restyles the context menu and nothing else.
API Reference
Every prop the styling does not consume is forwarded to the Context Menu primitive, so the tables below are the whole surface — the behavior props and the styling ones together.
ContextMenu
The root. Owns whether the menu is open and provides that to the parts inside it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
onOpenChange | (open: boolean) => void | — | Runs whenever the menu opens or closes. |
preventScroll | boolean | true | When true, the page behind cannot scroll while the menu is open. The panel can still scroll if you give it overflow. |
ContextMenuTrigger
The area the menu belongs to. Right-clicking (or long-pressing on touch) opens the menu at the pointer. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | Readable<boolean> | boolean | false | Falls back to the native browser menu. |
| Data attribute | Value |
|---|---|
[data-context-menu-trigger] | Present |
[data-state] | "open" | "closed" |
[data-disabled] | Present when disabled |
ContextMenuContent
The floating panel of items. Sets role="menu". Styled as a popover panel that fades and scales in from the side it opens on, and hides itself when closed. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "bottom" | Preferred side of the anchor to place the panel. |
align | "start" | "center" | "end" | "start" | How the panel aligns along the chosen side. |
offset | number | 0 | Distance in pixels between the anchor and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-context-menu-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
ContextMenuItem
One action. Sets role="menuitem"; focus follows the pointer. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Runs when the item is activated with a click, Enter, or Space. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-context-menu-item] | Present |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
ContextMenuCheckboxGroup
Wraps checkbox items and owns which of them are checked, as one array of values. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<ItemValue[]> | ItemValue[] | [] | The values of the checked items, where ItemValue is string | number. Pass a signal to control them from outside. |
onValueChange | (value: ItemValue[]) => void | — | Runs whenever the set of checked items changes. |
| Data attribute | Value |
|---|---|
[data-context-menu-checkbox-group] | Present |
ContextMenuCheckboxItem
An item holding a checked state. Sets role="menuitemcheckbox" and aria-checked. Renders its own check indicator, shown while the item is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
indicator | Child | — | Drawn in place of the default check. The left padding the default one is absolutely positioned into comes off with it, so a custom indicator sits in the row's flow. |
value | ItemValue | — | Identifies the item inside a checkbox group, as a string or a number. Must be unique within the group; ignored outside one. |
checked | Signal<boolean> | boolean | false | The checked state; selecting toggles it. Pass a signal to control it from outside. Inside a checkbox group the group's value owns it instead. |
onCheckedChange | (checked: boolean) => void | — | Runs whenever the item's checked state changes, inside a group or on its own. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-context-menu-checkbox-item] | Present |
[data-value] | The item's value, when it has one |
[data-state] | "checked" | "unchecked" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
ContextMenuRadioGroup
Wraps radio items and owns which one is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<ItemValue | null> | ItemValue | null | null | The checked item, where ItemValue is string | number. Pass a signal to control it from outside. |
onValueChange | (value: ItemValue | null) => void | — | Runs whenever the checked item changes. null once nothing is checked. |
| Data attribute | Value |
|---|---|
[data-context-menu-radio-group] | Present |
ContextMenuRadioItem
One choice in a radio group. Sets role="menuitemradio" and aria-checked. Renders its own dot indicator, shown while the item is selected. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | ItemValue | — | Identifies the item, as a string or a number. Must be unique within the radio group. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-context-menu-radio-item] | Present |
[data-state] | "checked" | "unchecked" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
ContextMenuSub
A nested menu. Wraps a sub trigger and its sub content inside a parent content.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state. |
onOpenChange | (open: boolean) => void | — | Runs whenever the submenu opens or closes. |
ContextMenuSubTrigger
The item that opens its submenu — on hover after openDelay, ArrowRight, Enter, Space, or click. Sets aria-haspopup and aria-expanded. The styled trigger appends a chevron pointing into the submenu. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
openDelay | number | 100 | How long the pointer must rest on the trigger before the submenu opens, in milliseconds. |
disabled | Readable<boolean> | boolean | false | Prevents opening the submenu; the keyboard skips the item. |
| Data attribute | Value |
|---|---|
[data-context-menu-sub-trigger] | Present |
[data-context-menu-item] | Present |
[data-state] | "open" | "closed" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
ContextMenuSubContent
A submenu's panel. Styled like the content panel but without the transition — a submenu should feel instant. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
side | "top" | "bottom" | "left" | "right" | "right" | Preferred side of the sub trigger to place the panel. |
align | "start" | "center" | "end" | "start" | How the panel aligns along the chosen side. |
offset | number | 8 | Distance in pixels between the sub trigger and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-context-menu-sub-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
ContextMenuGroup
Wraps related items in role="group", labeled by the heading placed inside it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-context-menu-group] | Present |
ContextMenuGroupHeading
Names the group it sits in; the group points aria-labelledby at it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-context-menu-group-heading] | Present |
ContextMenuSeparator
A role="separator" line between sections. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-context-menu-separator] | Present |
ContextMenuPortal
The core Portal helper, for rendering the panel into another DOM parent to escape overflow and stacking.