Context Menu

Displays a menu of actions, opened by a right click or a long press.

Right click hereLong press here
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-react

Set 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.

components/ui/context-menu.tsx
"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.

Right click hereLong press here
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>
  )
}

Nest secondary actions in a ContextMenuSub. A submenu anchors to its trigger row rather than to the cursor.

Right click hereLong press here
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.

Right click hereLong press here
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.

Right click hereLong press here
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.

Right click hereLong press here
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.

Right click hereLong press here
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.

Right click hereLong press here
"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.

Right click hereLong press here
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.

top
right
bottom
left
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

PropTypeDefault
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""right"
sideOffsetnumber0
align"start" | "center" | "end""start"
alignOffsetnumber4

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

PropTypeDefault
variant"default" | "destructive""default"
insetboolean—
disabledboolean—

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

PropTypeDefault
checkedboolean—
defaultCheckedboolean—
onCheckedChange(checked: boolean) => void—

ContextMenuRadioGroup

PropTypeDefault
valuestring—
defaultValuestring—
onValueChange(value: string) => void—

Data attributes

AttributeOnEffect
data-slot="context-menu-trigger"the triggerTargets the area from a parent
data-slot="context-menu-content"the popupPopup surface
data-side, data-alignthe popupBase UI writes them. data-side picks the slide direction
data-starting-style, data-ending-stylethe popupBase UI writes them while the popup animates in or out
data-slot="context-menu-item"an itemTargets items from a parent
data-variantan itemReflects the variant prop
data-insetan itemPresent when inset is set
data-highlighted, data-disabledan itemBase UI writes them