Context Menu
Displays a menu of actions, opened by a right click or a long press.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
menu: { minWidth: "12rem" },
})
export default function ContextMenuDemo() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent xstyle={styles.menu}>
<ContextMenuGroup>
<ContextMenuItem>
Back
<ContextMenuShortcut>⌘[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>
Forward
<ContextMenuShortcut>⌘]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Reload
<ContextMenuShortcut>⌘R</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Save Page...</ContextMenuItem>
<ContextMenuItem>Create Shortcut...</ContextMenuItem>
<ContextMenuItem>Name Window...</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem>Developer Tools</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuCheckboxItem defaultChecked>Show Bookmarks</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem>Show Full URLs</ContextMenuCheckboxItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>People</ContextMenuLabel>
<ContextMenuRadioGroup defaultValue="pedro">
<ContextMenuRadioItem value="pedro">Pedro Duarte</ContextMenuRadioItem>
<ContextMenuRadioItem value="colm">Colm Tuite</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}Installation
Install the following dependencies:
pnpm add @base-ui/react @stylexjs/stylex clsx lucide-reactnpm install @base-ui/react @stylexjs/stylex clsx lucide-reactyarn add @base-ui/react @stylexjs/stylex clsx lucide-reactbun add @base-ui/react @stylexjs/stylex clsx lucide-reactSet up the token layer and the StyleX compiler, if you have not already. See
Installation, and paste states.ts and state-styles.ts
from the same page. The popup surface is shared, and the items read their
highlighted and disabled styles from Base UI's state. Include interop.css
for item icon sizing.
Copy and paste the following code into your project.
"use client"
import { useRender } from "@base-ui/react/use-render"
import type { CSSProperties } from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { Check, ChevronRight } from "lucide-react"
import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import {
color,
duration,
ease,
layer,
leading,
radius,
shadow,
space,
text,
tracking,
} from "@/theme/tokens.stylex"
const styles = stylex.create({
hidden: { opacity: 0 },
trigger: { userSelect: "none" },
content: {
zIndex: layer.popup,
maxHeight: "var(--available-height)",
minWidth: "9rem",
overflowX: "hidden",
overflowY: "auto",
"--carbon-popup-radius": radius.lg,
borderRadius: "var(--carbon-popup-radius)",
padding: space.s1,
transformOrigin: "var(--transform-origin)",
transitionProperty: "opacity, transform",
transitionDuration: {
default: duration.fade,
"@media (prefers-reduced-motion: reduce)": "0s",
},
transitionTimingFunction: ease.standard,
},
subContent: {
width: "auto",
minWidth: "8rem",
boxShadow: shadow.popupLifted,
},
label: {
paddingInline: space.s1_5,
paddingBlock: space.s1,
fontSize: text.xs,
lineHeight: leading.xs,
fontWeight: 500,
color: color.mutedForeground,
},
item: {
position: "relative",
display: "flex",
alignItems: "center",
gap: space.s1_5,
cursor: "default",
userSelect: "none",
paddingInline: space.s1_5,
paddingBlock: space.s1,
fontSize: text.sm,
lineHeight: leading.sm,
outlineStyle: "none",
"--carbon-icon-size": space.s4,
},
inset: { paddingInlineStart: space.s7 },
destructive: { color: color.destructive },
indicatorItem: { paddingInlineEnd: space.s8 },
indicator: {
pointerEvents: "none",
position: "absolute",
insetInlineEnd: space.s2,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
chevron: { marginInlineStart: "auto" },
separator: {
marginInline: `calc(-1 * ${space.s1})`,
marginBlock: space.s1,
height: 1,
backgroundColor: color.border,
},
shortcut: {
marginInlineStart: "auto",
fontSize: text.xs,
lineHeight: leading.xs,
letterSpacing: tracking.widest,
color: color.mutedForeground,
},
})
type ItemVariant = "default" | "destructive"
const slide = stylex.create({
top: { transform: `scale(0.95) translateY(${space.s2})` },
bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
left: { transform: `scale(0.95) translateX(${space.s2})` },
right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
"inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
"inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
})
type Overrides = { xstyle?: StyleXStyles; className?: string }
type StateOverrides = Overrides & { style?: CSSProperties }
function ContextMenu(props: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root {...props} />
}
function ContextMenuPortal(props: ContextMenuPrimitive.Portal.Props) {
return <ContextMenuPrimitive.Portal {...props} />
}
type ContextMenuTriggerProps = Omit<ContextMenuPrimitive.Trigger.Props, "className"> & Overrides
function ContextMenuTrigger({ xstyle, className, ...props }: ContextMenuTriggerProps) {
const sx = stylex.props(styles.trigger, xstyle)
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
{...sx}
{...props}
className={clsx(sx.className, className)}
/>
)
}
type ContextMenuContentProps = Omit<ContextMenuPrimitive.Popup.Props, "className" | "style"> &
Pick<ContextMenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset"> &
StateOverrides
function ContextMenuContent({
xstyle,
style,
className,
side = "right",
sideOffset = 0,
align = "start",
alignOffset = 4,
...props
}: ContextMenuContentProps) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...stylex.props(states.positioner)}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
{...props}
{...stateStyles(
(state) => [
states.popupSurface,
styles.content,
(state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
styles.hidden,
(state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
slide[state.side],
xstyle,
],
{ className, style },
)}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup(props: ContextMenuPrimitive.Group.Props) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
type ContextMenuLabelProps = Omit<ContextMenuPrimitive.GroupLabel.Props, "className"> &
Overrides & { inset?: boolean }
function ContextMenuLabel({ xstyle, className, inset, ...props }: ContextMenuLabelProps) {
const sx = stylex.props(styles.label, inset && styles.inset, xstyle)
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset || undefined}
{...sx}
{...props}
className={clsx(sx.className, className)}
/>
)
}
type ContextMenuItemProps = Omit<ContextMenuPrimitive.Item.Props, "className" | "style"> &
StateOverrides & { inset?: boolean; variant?: ItemVariant }
function ContextMenuItem({
xstyle,
style,
className,
inset,
variant = "default",
...props
}: ContextMenuItemProps) {
const destructive = variant === "destructive"
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset || undefined}
data-variant={variant}
{...props}
{...stateStyles(
(state) => [
styles.item,
states.popupItem,
inset && styles.inset,
destructive && styles.destructive,
state.highlighted &&
(destructive ? states.itemHighlightDestructive : states.itemHighlight),
state.disabled && states.disabled,
xstyle,
],
{ className, style, hook: "carbon-icons" },
)}
/>
)
}
type ContextMenuCheckboxItemProps = Omit<
ContextMenuPrimitive.CheckboxItem.Props,
"className" | "style"
> &
StateOverrides & { inset?: boolean }
function ContextMenuCheckboxItem({
xstyle,
style,
className,
inset,
children,
...props
}: ContextMenuCheckboxItemProps) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset || undefined}
{...props}
{...stateStyles(
(state) => [
styles.item,
states.popupItem,
styles.indicatorItem,
inset && styles.inset,
state.highlighted && states.itemHighlight,
state.disabled && states.disabled,
xstyle,
],
{ className, style, hook: "carbon-icons" },
)}
>
<span data-slot="context-menu-checkbox-item-indicator" {...stylex.props(styles.indicator)}>
<ContextMenuPrimitive.CheckboxItemIndicator>
<Check />
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup(props: ContextMenuPrimitive.RadioGroup.Props) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
type ContextMenuRadioItemProps = Omit<ContextMenuPrimitive.RadioItem.Props, "className" | "style"> &
StateOverrides & { inset?: boolean }
function ContextMenuRadioItem({
xstyle,
style,
className,
inset,
children,
...props
}: ContextMenuRadioItemProps) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset || undefined}
{...props}
{...stateStyles(
(state) => [
styles.item,
states.popupItem,
styles.indicatorItem,
inset && styles.inset,
state.highlighted && states.itemHighlight,
state.disabled && states.disabled,
xstyle,
],
{ className, style, hook: "carbon-icons" },
)}
>
<span data-slot="context-menu-radio-item-indicator" {...stylex.props(styles.indicator)}>
<ContextMenuPrimitive.RadioItemIndicator>
<Check />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
type ContextMenuSeparatorProps = Omit<ContextMenuPrimitive.Separator.Props, "className"> & Overrides
function ContextMenuSeparator({ xstyle, className, ...props }: ContextMenuSeparatorProps) {
const sx = stylex.props(styles.separator, xstyle)
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
{...sx}
{...props}
className={clsx(sx.className, className)}
/>
)
}
type ContextMenuShortcutProps = useRender.ComponentProps<"span"> & Overrides
function ContextMenuShortcut({
render,
ref,
style,
xstyle,
className,
...props
}: ContextMenuShortcutProps) {
const sx = stylex.props(styles.shortcut, xstyle)
return useRender({
defaultTagName: "span",
render,
ref,
props: {
"data-slot": "context-menu-shortcut",
...sx,
...props,
className: clsx(sx.className, className),
style: { ...sx.style, ...style },
},
})
}
function ContextMenuSub(props: ContextMenuPrimitive.SubmenuRoot.Props) {
return <ContextMenuPrimitive.SubmenuRoot {...props} />
}
type ContextMenuSubTriggerProps = Omit<
ContextMenuPrimitive.SubmenuTrigger.Props,
"className" | "style"
> &
StateOverrides & { inset?: boolean }
function ContextMenuSubTrigger({
xstyle,
style,
className,
inset,
children,
...props
}: ContextMenuSubTriggerProps) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset || undefined}
{...props}
{...stateStyles(
(state) => [
styles.item,
states.popupItem,
inset && styles.inset,
(state.highlighted || state.open) && states.itemHighlight,
state.disabled && states.disabled,
xstyle,
],
{ className, style, hook: "carbon-icons" },
)}
>
{children}
<ChevronRight {...stylex.props(styles.chevron)} />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
xstyle,
side = "right",
sideOffset = 0,
align = "start",
alignOffset = -3,
...props
}: ContextMenuContentProps) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
xstyle={[styles.subContent, xstyle]}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuPortal,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
}Update the import paths to match your project setup.
Usage
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"<ContextMenu>
<ContextMenuTrigger>Right click here</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Rename</ContextMenuItem>
<ContextMenuItem>Duplicate</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>ContextMenuTrigger renders a bare <div> with no size and no appearance. Give
it both, or pass the element you already have through render. A right click or
a half-second long press opens the menu.
The menu anchors to the point that opened it, not to the trigger box. side and
align are measured from the cursor, so a trigger that fills the viewport still
gets a menu beside the pointer.
Basic
The content holds items directly. Nothing has to be grouped.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuBasic() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Back</ContextMenuItem>
<ContextMenuItem disabled>Forward</ContextMenuItem>
<ContextMenuItem>Reload</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}Submenu
Nest secondary actions in a ContextMenuSub. A submenu anchors to its trigger
row rather than to the cursor.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuSubmenu() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
Copy
<ContextMenuShortcut>⌘C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Cut
<ContextMenuShortcut>⌘X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>More Tools</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Save Page...</ContextMenuItem>
<ContextMenuItem>Create Shortcut...</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Share</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Email Link</ContextMenuItem>
<ContextMenuItem>Copy Link</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem>Developer Tools</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
)
}Shortcuts
Use ContextMenuShortcut to show a key combination at the trailing edge of an
item. It displays the key only. Wire the binding yourself.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuShortcuts() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuItem>
Back
<ContextMenuShortcut>⌘[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem disabled>
Forward
<ContextMenuShortcut>⌘]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Reload
<ContextMenuShortcut>⌘R</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem>
Save
<ContextMenuShortcut>⌘S</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Save As...
<ContextMenuShortcut>⇧⌘S</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}Groups
Wrap related items in a ContextMenuGroup, name it with ContextMenuLabel and
divide groups with ContextMenuSeparator. A screen reader announces the label
as the name of its group.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuGroups() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuLabel>File</ContextMenuLabel>
<ContextMenuItem>
New File
<ContextMenuShortcut>⌘N</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Open File
<ContextMenuShortcut>⌘O</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Edit</ContextMenuLabel>
<ContextMenuItem>
Undo
<ContextMenuShortcut>⌘Z</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
Redo
<ContextMenuShortcut>⇧⌘Z</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuItem variant="destructive">
Delete
<ContextMenuShortcut>⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}Icons
Place an icon before the label. The item sizes it.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
import { ClipboardPaste, Copy, Scissors, Trash } from "lucide-react"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuIcons() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<Copy />
Copy
</ContextMenuItem>
<ContextMenuItem>
<Scissors />
Cut
</ContextMenuItem>
<ContextMenuItem>
<ClipboardPaste />
Paste
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}Checkboxes
Use ContextMenuCheckboxItem for items that toggle. Pass checked and
onCheckedChange to control it, or defaultChecked to leave it uncontrolled.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
menu: { minWidth: "12rem" },
})
export default function ContextMenuCheckboxes() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent xstyle={styles.menu}>
<ContextMenuGroup>
<ContextMenuLabel>Appearance</ContextMenuLabel>
<ContextMenuCheckboxItem defaultChecked>Show Bookmarks Bar</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem>Show Full URLs</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem disabled>Show Developer Tools</ContextMenuCheckboxItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}Radio group
Use ContextMenuRadioGroup to hold one value across its ContextMenuRadioItems.
Pass value and onValueChange, or defaultValue.
"use client"
import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuRadio() {
const [person, setPerson] = useState("pedro")
const [theme, setTheme] = useState("light")
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuGroup>
<ContextMenuLabel>People</ContextMenuLabel>
<ContextMenuRadioGroup value={person} onValueChange={setPerson}>
<ContextMenuRadioItem value="pedro">Pedro Duarte</ContextMenuRadioItem>
<ContextMenuRadioItem value="colm">Colm Tuite</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuLabel>Theme</ContextMenuLabel>
<ContextMenuRadioGroup value={theme} onValueChange={setTheme}>
<ContextMenuRadioItem value="light">Light</ContextMenuRadioItem>
<ContextMenuRadioItem value="dark">Dark</ContextMenuRadioItem>
<ContextMenuRadioItem value="system">System</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
)
}Destructive
Set variant="destructive" on an item to colour it and its icon.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, text } from "@/theme/tokens.stylex"
import { Archive, Pencil, Share, Trash } from "lucide-react"
const styles = stylex.create({
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
width: "100%",
maxWidth: "20rem",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
fine: { display: { default: "none", "@media (pointer: fine)": "inline" } },
coarse: { display: { default: "none", "@media (pointer: coarse)": "inline" } },
})
export default function ContextMenuDestructive() {
return (
<ContextMenu>
<ContextMenuTrigger xstyle={styles.dropzone}>
<span {...stylex.props(styles.fine)}>Right click here</span>
<span {...stylex.props(styles.coarse)}>Long press here</span>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<Pencil />
Edit
</ContextMenuItem>
<ContextMenuItem>
<Share />
Share
</ContextMenuItem>
<ContextMenuItem>
<Archive />
Archive
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}Sides
Set side to move the menu around the cursor. Use "inline-start" or
"inline-end" instead of "left" or "right" to have the side follow the
writing direction.
import * as stylex from "@stylexjs/stylex"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { leading, radius, space, text } from "@/theme/tokens.stylex"
const styles = stylex.create({
grid: {
display: "grid",
width: "100%",
maxWidth: "24rem",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: space.s4,
},
dropzone: {
display: "flex",
aspectRatio: "16 / 9",
alignItems: "center",
justifyContent: "center",
borderRadius: radius.xl,
borderWidth: 1,
borderStyle: "dashed",
fontSize: text.sm,
lineHeight: leading.sm,
},
})
const sides = ["top", "right", "bottom", "left"] as const
export default function ContextMenuSides() {
return (
<div {...stylex.props(styles.grid)}>
{sides.map((side) => (
<ContextMenu key={side}>
<ContextMenuTrigger xstyle={styles.dropzone}>{side}</ContextMenuTrigger>
<ContextMenuContent side={side}>
<ContextMenuItem>Back</ContextMenuItem>
<ContextMenuItem>Forward</ContextMenuItem>
<ContextMenuItem>Reload</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</div>
)
}API reference
Pass render to replace a part's element or compose it with another component.
Every part wraps the Base UI ContextMenu part of the same name and accepts its
props. See the
Base UI reference for the
full list. Each styled part also takes xstyle and className. The items and
ContextMenuSubTrigger take style as well, which is real inline CSS applied
over the compiled styles rather than a StyleX override.
ContextMenu takes the Menu root's props apart from modal, openOnHover,
delay and closeDelay, which a context menu has no use for.
ContextMenuContent
| Prop | Type | Default |
|---|---|---|
side | "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end" | "right" |
sideOffset | number | 0 |
align | "start" | "center" | "end" | "start" |
alignOffset | number | 4 |
The defaults place the menu below and to the right of the pointer, the way a
native menu does. The popup is never narrower than 9rem. Set a wider floor
through xstyle.
ContextMenuSubContent
The same props, with alignOffset={-3} as its default, which pulls the submenu
back by the popup padding so its first item lines up with the row that opened
it.
ContextMenuItem
| Prop | Type | Default |
|---|---|---|
variant | "default" | "destructive" | "default" |
inset | boolean | — |
disabled | boolean | — |
inset pads the leading edge by the width of an indicator column, so a plain
item lines up with checkbox and radio items beside it. ContextMenuLabel,
ContextMenuSubTrigger, ContextMenuCheckboxItem and ContextMenuRadioItem
take inset too.
ContextMenuCheckboxItem
| Prop | Type | Default |
|---|---|---|
checked | boolean | — |
defaultChecked | boolean | — |
onCheckedChange | (checked: boolean) => void | — |
ContextMenuRadioGroup
| Prop | Type | Default |
|---|---|---|
value | string | — |
defaultValue | string | — |
onValueChange | (value: string) => void | — |
Data attributes
| Attribute | On | Effect |
|---|---|---|
data-slot="context-menu-trigger" | the trigger | Targets the area from a parent |
data-slot="context-menu-content" | the popup | Popup surface |
data-side, data-align | the popup | Base UI writes them. data-side picks the slide direction |
data-starting-style, data-ending-style | the popup | Base UI writes them while the popup animates in or out |
data-slot="context-menu-item" | an item | Targets items from a parent |
data-variant | an item | Reflects the variant prop |
data-inset | an item | Present when inset is set |
data-highlighted, data-disabled | an item | Base UI writes them |