# Context Menu

> Show actions at a pointer location with keyboard and touch access.

Source: https://www.honestui.com/docs/components/context-menu

```tsx
"use client"

import * as React from "react"
import {
  Archive as ArchiveIcon,
  Copy as CopyIcon,
  Folder as FolderIcon,
  Pencil as PencilIcon,
  Trash as TrashIcon,
} from "honestui/icons"

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/honest-ui/ui/context-menu"

export function ContextMenuDemo() {
  const [lastAction, setLastAction] = React.useState("No action selected")

  return (
    <div className="w-full max-w-md space-y-3">
      <ContextMenu>
        <ContextMenuTrigger
          className="flex min-h-48 w-full items-center justify-center rounded-sm border border-dashed border-border bg-muted/30 p-8 text-center text-sm text-muted-foreground"
          tabIndex={0}
        >
          <span>
            Right-click or long-press this area.
            <span className="mt-1 block text-xs">
              With a keyboard, focus it and press Shift+F10.
            </span>
          </span>
        </ContextMenuTrigger>
        <ContextMenuContent>
          <ContextMenuGroup>
            <ContextMenuLabel>Document</ContextMenuLabel>
            <ContextMenuItem
              leadingIcon={<CopyIcon />}
              onClick={() => setLastAction("Copied document link")}
            >
              Copy link
            </ContextMenuItem>
            <ContextMenuItem
              leadingIcon={<PencilIcon />}
              onClick={() => setLastAction("Rename selected")}
            >
              Rename
            </ContextMenuItem>
          </ContextMenuGroup>
          <ContextMenuSeparator />
          <ContextMenuSub>
            <ContextMenuSubTrigger leadingIcon={<FolderIcon />}>
              Move to
            </ContextMenuSubTrigger>
            <ContextMenuSubContent>
              <ContextMenuItem
                onClick={() => setLastAction("Moved to Projects")}
              >
                Projects
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => setLastAction("Moved to Research")}
              >
                Research
              </ContextMenuItem>
              <ContextMenuItem
                leadingIcon={<ArchiveIcon />}
                onClick={() => setLastAction("Moved to Archive")}
              >
                Archive
              </ContextMenuItem>
            </ContextMenuSubContent>
          </ContextMenuSub>
          <ContextMenuSeparator />
          <ContextMenuItem
            leadingIcon={<TrashIcon />}
            variant="destructive"
            onClick={() => setLastAction("Delete selected")}
          >
            Delete
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
      <p className="text-center text-xs text-muted-foreground" aria-live="polite">
        {lastAction}
      </p>
    </div>
  )
}

```

## Overview [#overview]

Use Context Menu for secondary actions that relate to a specific surface, document, or selected object. It opens where a person right-clicks or long-presses, keeping the available actions close to the object they affect.

Do not make a context menu the only way to reach an essential action. Many people will not discover it, some input methods do not provide a familiar context-menu gesture, and actions buried behind an undocumented right-click are invisible during task flows and screen-reader linear navigation. Keep primary and high-frequency actions visible elsewhere; treat the context menu as an accelerant, not a doorway.

## Anatomy [#anatomy]

Context Menu includes a root, trigger region, popup, items, optional groups and labels, separators, an empty state for filtered results, and nested submenus. Items accept leading and trailing icons through props, and every part is defined in `context-menu.tsx` and exported as a named PascalCase component, matching the rest of Honest UI.

## Behavior [#behavior]

**Opening.** The trigger region opens the menu on right-click or long-press. Unlike a click-opened Menu, arrow keys do not open it — the gesture is inherently pointer- or platform-driven, which is why the keyboard path below matters.

**Navigating and activating.** Once open, behavior matches Menu: arrow keys move the highlight without wrapping (`loopFocus` is fixed off), <kbd>Home</kbd> and <kbd>End</kbd> jump to the ends of the list, typeahead jumps to matching labels, and <kbd>Enter</kbd> or <kbd>Space</kbd> activates the highlighted item. Items close the menu after activation unless `closeOnClick` is false. The opening right-click itself never activates an item underneath the cursor.

**Closing.** <kbd>Escape</kbd> closes the menu, and clicks outside close it too; the right-click that opened the menu cannot immediately dismiss it.

**Submenus.** Pressing <kbd>Arrow Right</kbd> on a highlighted submenu trigger opens the child menu; <kbd>Escape</kbd> closes only that child before it reaches the parent.

**Searchable mode.** Set `autocomplete` on the root when a genuinely long command list benefits from filtering. In automatic mode, item `value` strings — or visible text when no value is set — are matched against the query, and labels, separators, and groups hide while filtering so results stay one navigable list. The popup switches to `role="dialog"` with the search input focused on open; <kbd>Enter</kbd> or <kbd>Arrow Right</kbd> opens the highlighted submenu, and <kbd>Escape</kbd> backs out of the submenu first. Use `autocompleteMode="manual"` when your application owns filtering; `inputValue` and `onInputValueChange` support both controlled and uncontrolled queries.

## Accessibility [#accessibility]

The trigger renders a `<div>`, not a button, so give a non-interactive region `tabIndex={0}` and a name describing what it acts on. Keyboard users can then focus it and open the menu with <kbd>Shift</kbd>+<kbd>F10</kbd>, the <kbd>Menu</kbd> key, or their platform's equivalent — the same command the browser already uses on focused elements. Because there is no visual affordance, announce the capability in nearby text or a tooltip, as the examples do.

Long-press covers touch, but its discoverability is worse than right-click's; anything reachable only here will be missed. Mark unavailable items as disabled instead of removing them when their absence would confuse, keep action labels concise and verb-first, and give the searchable variant a specific `searchLabel` when "Search menu items" does not describe the scope.

Base UI supplies the menu roles, focus management, arrow-key movement, typeahead, Escape behavior, and long-press handling. Colors come from theme tokens, so highlights and the destructive variant adapt to dark mode automatically. Item padding uses logical properties and the popup mirrors its offsets in right-to-left locales; long labels wrap inside items rather than widening the popup past the viewport.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;context-menu&#x22;]" />
  

  
    
      
        Install the required dependency:
      

      ```bash
      npm install @base-ui/react
      ```

      
        Copy and paste the component into your project.
      

      ### components/ui/context-menu.tsx

```tsx
"use client"

import * as React from "react"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { ChevronRight as ChevronRightIcon } from "honestui/icons"

import { cn } from "@/lib/utils"

type AutocompleteMode = "auto" | "manual"

type ContextMenuContextValue = {
  autocomplete: boolean
  autocompleteMode: AutocompleteMode
  inputRef: React.RefObject<HTMLInputElement | null>
  inputValue: string
  isInitialRenderRef: React.RefObject<boolean>
  onInputValueChange: (value: string) => void
  parent?: ContextMenuContextValue
  shouldFilter: boolean
}

const ContextMenuContext = React.createContext<ContextMenuContextValue | null>(
  null
)

function useContextMenuContext() {
  const context = React.useContext(ContextMenuContext)

  if (!context) {
    throw new Error("Context menu parts must be used within ContextMenu")
  }

  return context
}

function getTextContent(node: React.ReactNode): string {
  if (typeof node === "string" || typeof node === "number") {
    return String(node)
  }

  if (Array.isArray(node)) {
    return node.map(getTextContent).join(" ")
  }

  if (React.isValidElement<{ children?: React.ReactNode }>(node)) {
    return getTextContent(node.props.children)
  }

  return ""
}

function matchesSearch(
  value: string | undefined,
  children: React.ReactNode,
  query: string
) {
  const normalizedQuery = query.trim().toLocaleLowerCase()

  if (!normalizedQuery) return true

  return (value ?? getTextContent(children))
    .toLocaleLowerCase()
    .includes(normalizedQuery)
}

function dispatchKeyboardEvent(element: Element, key: string) {
  element.dispatchEvent(
    new KeyboardEvent("keydown", {
      bubbles: true,
      cancelable: true,
      key,
    })
  )
}

function isSubmenuTrigger(element: Element) {
  return element.matches('[data-slot="context-menu-subtrigger"]')
}

function isSubmenuOpen(element: Element) {
  return element.hasAttribute("data-popup-open")
}

interface NormalContextMenuRootProps
  extends ContextMenuPrimitive.Root.Props {
  autocomplete?: false
  autocompleteMode?: never
  defaultInputValue?: never
  inputValue?: never
  onInputValueChange?: never
}

interface AutocompleteContextMenuRootProps
  extends ContextMenuPrimitive.Root.Props {
  autocomplete: true
  autocompleteMode?: AutocompleteMode
  defaultInputValue?: string
  inputValue?: string
  onInputValueChange?: (value: string) => void
}

type ContextMenuRootProps =
  | NormalContextMenuRootProps
  | AutocompleteContextMenuRootProps

function ContextMenu({
  autocomplete = false,
  autocompleteMode = "auto",
  inputValue: controlledInputValue,
  onInputValueChange,
  defaultInputValue = "",
  open: controlledOpen,
  onOpenChange,
  defaultOpen = false,
  ...props
}: ContextMenuRootProps) {
  const [uncontrolledInputValue, setUncontrolledInputValue] =
    React.useState(defaultInputValue)
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const isInitialRenderRef = React.useRef(true)
  const inputValue = controlledInputValue ?? uncontrolledInputValue
  const open = controlledOpen ?? uncontrolledOpen

  const setInputValue = React.useCallback(
    (value: string) => {
      if (controlledInputValue === undefined) {
        setUncontrolledInputValue(value)
      }
      onInputValueChange?.(value)
    },
    [controlledInputValue, onInputValueChange]
  )

  const handleOpenChange = React.useCallback<
    NonNullable<ContextMenuPrimitive.Root.Props["onOpenChange"]>
  >(
    (nextOpen, eventDetails) => {
      if (controlledOpen === undefined) {
        setUncontrolledOpen(nextOpen)
      }

      if (!nextOpen && autocomplete) {
        setInputValue("")
        isInitialRenderRef.current = true
      }

      onOpenChange?.(nextOpen, eventDetails)
    },
    [autocomplete, controlledOpen, onOpenChange, setInputValue]
  )

  const context = React.useMemo<ContextMenuContextValue>(
    () => ({
      autocomplete,
      autocompleteMode,
      inputRef,
      inputValue,
      isInitialRenderRef,
      onInputValueChange: setInputValue,
      shouldFilter: autocomplete && autocompleteMode === "auto",
    }),
    [autocomplete, autocompleteMode, inputValue, setInputValue]
  )

  return (
    <ContextMenuContext.Provider value={context}>
      <ContextMenuPrimitive.Root
        open={open}
        onOpenChange={handleOpenChange}
        loopFocus={false}
        {...props}
      />
    </ContextMenuContext.Provider>
  )
}
interface NormalContextMenuSubProps
  extends ContextMenuPrimitive.SubmenuRoot.Props {
  autocomplete?: false
  autocompleteMode?: never
  defaultInputValue?: never
  inputValue?: never
  onInputValueChange?: never
}

interface AutocompleteContextMenuSubProps
  extends ContextMenuPrimitive.SubmenuRoot.Props {
  autocomplete: true
  autocompleteMode?: AutocompleteMode
  defaultInputValue?: string
  inputValue?: string
  onInputValueChange?: (value: string) => void
}

type ContextMenuSubProps =
  | NormalContextMenuSubProps
  | AutocompleteContextMenuSubProps

function ContextMenuSub({
  autocomplete = false,
  autocompleteMode = "auto",
  inputValue: controlledInputValue,
  onInputValueChange,
  defaultInputValue = "",
  open: controlledOpen,
  onOpenChange,
  defaultOpen = false,
  ...props
}: ContextMenuSubProps) {
  const parent = useContextMenuContext()
  const [uncontrolledInputValue, setUncontrolledInputValue] =
    React.useState(defaultInputValue)
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const isInitialRenderRef = React.useRef(true)
  const inputValue = controlledInputValue ?? uncontrolledInputValue
  const open = controlledOpen ?? uncontrolledOpen

  const setInputValue = React.useCallback(
    (value: string) => {
      if (controlledInputValue === undefined) {
        setUncontrolledInputValue(value)
      }
      onInputValueChange?.(value)
    },
    [controlledInputValue, onInputValueChange]
  )

  const handleOpenChange = React.useCallback<
    NonNullable<ContextMenuPrimitive.SubmenuRoot.Props["onOpenChange"]>
  >(
    (nextOpen, eventDetails) => {
      if (controlledOpen === undefined) {
        setUncontrolledOpen(nextOpen)
      }

      if (!nextOpen && autocomplete) {
        setInputValue("")
        isInitialRenderRef.current = true
      }

      onOpenChange?.(nextOpen, eventDetails)
    },
    [autocomplete, controlledOpen, onOpenChange, setInputValue]
  )

  const context = React.useMemo<ContextMenuContextValue>(
    () => ({
      autocomplete,
      autocompleteMode,
      inputRef,
      inputValue,
      isInitialRenderRef,
      onInputValueChange: setInputValue,
      parent,
      shouldFilter: autocomplete && autocompleteMode === "auto",
    }),
    [
      autocomplete,
      autocompleteMode,
      inputValue,
      parent,
      setInputValue,
    ]
  )

  return (
    <ContextMenuContext.Provider value={context}>
      <ContextMenuPrimitive.SubmenuRoot
        open={open}
        onOpenChange={handleOpenChange}
        loopFocus={false}
        {...props}
      />
    </ContextMenuContext.Provider>
  )
}
type ContextMenuTriggerProps = ContextMenuPrimitive.Trigger.Props

function ContextMenuTrigger({ className, ...props }: ContextMenuTriggerProps) {
  return (
    <ContextMenuPrimitive.Trigger
      data-slot="context-menu-trigger"
      className={cn(
        "outline-none focus-visible:[outline:var(--hui-focus-ring)] focus-visible:outline-offset-[var(--hui-focus-ring-offset-accent)]",
        className
      )}
      {...props}
    />
  )
}
type ContextMenuCellProps = React.ComponentProps<"div"> & {
  inset?: boolean
  leadingIcon?: React.ReactNode
  trailingIcon?: React.ReactNode
  variant?: "default" | "destructive"
}

function ContextMenuCell({
  children,
  className,
  inset,
  leadingIcon,
  trailingIcon,
  variant = "default",
  ...props
}: ContextMenuCellProps) {
  return (
    <div
      data-inset={inset || undefined}
      data-variant={variant}
      className={cn(
        "relative flex items-center gap-[var(--hui-space-3)] p-[var(--hui-space-3)] outline-none select-none [font-size:var(--hui-font-size-control,13px)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-control,-0.003em)] [line-height:var(--hui-line-height-control,16px)] aria-disabled:pointer-events-none aria-disabled:text-[var(--hui-color-foreground-base-tertiary)] data-disabled:pointer-events-none data-disabled:text-[var(--hui-color-foreground-base-tertiary)] data-highlighted:cursor-pointer data-highlighted:rounded-[var(--hui-radius-2)] data-highlighted:bg-[var(--hui-color-background-base-primary-hover)] data-inset:ps-8 data-popup-open:cursor-pointer data-popup-open:rounded-[var(--hui-radius-2)] data-popup-open:bg-[var(--hui-color-background-base-primary-hover)] data-[variant=destructive]:text-[var(--hui-color-foreground-danger-primary)] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:text-[var(--hui-color-foreground-base-secondary)] [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {leadingIcon ? <span aria-hidden="true">{leadingIcon}</span> : null}
      {children}
      {trailingIcon ? (
        <span className="ms-auto" aria-hidden="true">
          {trailingIcon}
        </span>
      ) : null}
    </div>
  )
}

type ContextMenuItemProps = ContextMenuPrimitive.Item.Props & {
  inset?: boolean
  leadingIcon?: React.ReactNode
  trailingIcon?: React.ReactNode
  value?: string
  variant?: "default" | "destructive"
}

function ContextMenuItem({
  children,
  value,
  leadingIcon,
  trailingIcon,
  inset,
  variant,
  render,
  ...props
}: ContextMenuItemProps) {
  const { autocomplete, inputValue, shouldFilter } = useContextMenuContext()

  if (shouldFilter && !matchesSearch(value, children, inputValue)) {
    return null
  }

  const cell =
    render ?? (
      <ContextMenuCell
        inset={inset}
        leadingIcon={leadingIcon}
        trailingIcon={trailingIcon}
        variant={variant}
      />
    )

  if (autocomplete) {
    return (
      <AutocompletePrimitive.Item
        data-slot="context-menu-item"
        value={value ?? getTextContent(children)}
        render={<ContextMenuPrimitive.Item render={cell} />}
        {...props}
      >
        {children}
      </AutocompletePrimitive.Item>
    )
  }

  return (
    <ContextMenuPrimitive.Item
      data-slot="context-menu-item"
      render={cell}
      {...props}
    >
      {children}
    </ContextMenuPrimitive.Item>
  )
}
type ContextMenuSubTriggerProps =
  ContextMenuPrimitive.SubmenuTrigger.Props & {
    inset?: boolean
    leadingIcon?: React.ReactNode
    trailingIcon?: React.ReactNode
    value?: string
  }

function ContextMenuSubTrigger({
  children,
  value,
  trailingIcon = <ChevronRightIcon />,
  leadingIcon,
  inset,
  ...props
}: ContextMenuSubTriggerProps) {
  const { inputRef, parent } = useContextMenuContext()

  if (
    parent?.shouldFilter &&
    !matchesSearch(value, children, parent.inputValue)
  ) {
    return null
  }

  const cell = (
    <ContextMenuCell
      inset={inset}
      leadingIcon={leadingIcon}
      trailingIcon={trailingIcon}
    />
  )

  return (
    <ContextMenuPrimitive.SubmenuTrigger
      data-slot="context-menu-subtrigger"
      render={
        parent?.autocomplete ? (
          <AutocompletePrimitive.Item
            value={value ?? getTextContent(children)}
            render={cell}
            onPointerEnter={(event) => {
              if (document.activeElement !== parent.inputRef.current) {
                parent.inputRef.current?.focus()
              }
              props.onPointerEnter?.(event)
            }}
            onKeyDown={(event) => {
              requestAnimationFrame(() => inputRef.current?.focus())
              props.onKeyDown?.(event)
            }}
          />
        ) : (
          cell
        )
      }
      role={parent?.autocomplete ? "option" : "menuitem"}
      {...props}
    >
      {children}
    </ContextMenuPrimitive.SubmenuTrigger>
  )
}
type ContextMenuGroupProps = ContextMenuPrimitive.Group.Props

function ContextMenuGroup({ children, ...props }: ContextMenuGroupProps) {
  const { shouldFilter } = useContextMenuContext()

  if (shouldFilter) return <>{children}</>

  return (
    <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}>
      {children}
    </ContextMenuPrimitive.Group>
  )
}
type ContextMenuLabelProps = ContextMenuPrimitive.GroupLabel.Props

function ContextMenuLabel({ className, ...props }: ContextMenuLabelProps) {
  const { shouldFilter } = useContextMenuContext()

  if (shouldFilter) return null

  return (
    <ContextMenuPrimitive.GroupLabel
      data-slot="context-menu-label"
      className={cn(
        "px-[var(--hui-space-3)] py-[var(--hui-space-2)] [font-size:var(--hui-font-size-mini)] [font-weight:var(--hui-font-weight-medium)]",
        className
      )}
      {...props}
    />
  )
}
type ContextMenuSeparatorProps = ContextMenuPrimitive.Separator.Props

function ContextMenuSeparator({
  className,
  ...props
}: ContextMenuSeparatorProps) {
  const { shouldFilter } = useContextMenuContext()

  if (shouldFilter) return null

  return (
    <ContextMenuPrimitive.Separator
      data-slot="context-menu-separator"
      className={cn(
        "my-[var(--hui-space-2)] h-px bg-[var(--hui-color-border-base-primary)] [margin-inline:calc(var(--hui-space-3)*-1)]",
        className
      )}
      {...props}
    />
  )
}
function ContextMenuEmptyState({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="context-menu-empty-state"
      className={cn(
        "hidden p-[var(--hui-space-3)] text-center text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-control,13px)] only:block",
        className
      )}
      {...props}
    />
  )
}
type ContextMenuContentProps = ContextMenuPrimitive.Popup.Props & {
  align?: ContextMenuPrimitive.Positioner.Props["align"]
  alignOffset?: ContextMenuPrimitive.Positioner.Props["alignOffset"]
  searchLabel?: string
  searchPlaceholder?: string
  sideOffset?: ContextMenuPrimitive.Positioner.Props["sideOffset"]
}

function ContextMenuContent({
  ref,
  className,
  children,
  searchLabel = "Search menu items",
  searchPlaceholder = "Search...",
  sideOffset = 4,
  align = "start",
  alignOffset = 0,
  onFocus,
  ...props
}: ContextMenuContentProps) {
  const {
    autocomplete,
    inputValue,
    inputRef,
    isInitialRenderRef,
    onInputValueChange,
    parent,
  } = useContextMenuContext()
  const highlightedItem = React.useRef<
    [index: number, reason: "keyboard" | "pointer" | "none"]
  >([-1, "none"])
  const listRef = React.useRef<HTMLDivElement>(null)

  const focusInput = React.useCallback(() => {
    if (document.activeElement !== inputRef.current) {
      inputRef.current?.focus()
    }
  }, [inputRef])

  const highlightFirstItem = React.useCallback(() => {
    if (!isInitialRenderRef.current) return
    isInitialRenderRef.current = false

    const item = listRef.current?.querySelector('[role="option"]')
    item?.dispatchEvent(new PointerEvent("mousemove", { bubbles: true }))
  }, [isInitialRenderRef])

  const openHighlightedSubmenu = React.useCallback(() => {
    if (highlightedItem.current[0] === -1) return

    const items = listRef.current?.querySelectorAll('[role="option"]')
    const item = items?.[highlightedItem.current[0]]

    if (item && isSubmenuTrigger(item)) {
      dispatchKeyboardEvent(item, "ArrowRight")
    }
  }, [])

  const closeHighlightedSubmenu = React.useCallback(
    (event: React.KeyboardEvent<HTMLInputElement>) => {
      if (highlightedItem.current[0] === -1) return

      const items = listRef.current?.querySelectorAll('[role="option"]')
      const item = items?.[highlightedItem.current[0]]

      if (!item || !isSubmenuTrigger(item) || !isSubmenuOpen(item)) return

      dispatchKeyboardEvent(item, "Escape")
      event.stopPropagation()
    },
    []
  )

  const closeStaleSubmenu = React.useCallback((index: number) => {
    const items = listRef.current?.querySelectorAll('[role="option"]')
    const item = items?.[index]

    if (!item || !isSubmenuTrigger(item) || !isSubmenuOpen(item)) return

    dispatchKeyboardEvent(item, "Escape")
    item.dispatchEvent(new PointerEvent("pointerout", { bubbles: true }))
  }, [])

  return (
    <ContextMenuPrimitive.Portal>
      <ContextMenuPrimitive.Positioner
        data-slot="context-menu-positioner"
        className="z-[var(--hui-z-index-portal)]"
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
      >
        <ContextMenuPrimitive.Popup
          ref={ref}
          data-slot="context-menu-content"
          className={cn(
            "box-border max-h-(--available-height) min-w-48 origin-(--transform-origin) overflow-x-hidden overflow-y-auto overscroll-contain rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-primary)] p-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] shadow-[var(--hui-shadow-soft)] outline-none [font-size:var(--hui-font-size-control,13px)] [font-weight:var(--hui-font-weight-regular)] [letter-spacing:var(--hui-letter-spacing-control,-0.003em)] [line-height:var(--hui-line-height-control,16px)] [transition:opacity_var(--hui-duration-fast)_var(--hui-ease-out)] focus:outline-none focus-visible:outline-none data-ending-style:opacity-0 data-starting-style:opacity-0 motion-safe:[transition:opacity_var(--hui-duration-fast)_var(--hui-ease-out),transform_var(--hui-duration-fast)_var(--hui-ease-out)] motion-safe:data-ending-style:scale-[0.97] motion-safe:data-starting-style:scale-[0.97]",
            className
          )}
          role={autocomplete ? "dialog" : "menu"}
          onFocus={
            autocomplete || parent?.autocomplete
              ? (event) => {
                  focusInput()
                  event.stopPropagation()
                  highlightFirstItem()
                  onFocus?.(event)
                }
              : onFocus
          }
          {...props}
        >
          {autocomplete ? (
            <AutocompletePrimitive.Root
              inline
              open
              value={inputValue}
              onValueChange={(value) => onInputValueChange(value)}
              autoHighlight={Boolean(inputValue)}
              mode="none"
              loopFocus={false}
              onItemHighlighted={(_, eventDetails) => {
                if (
                  highlightedItem.current[1] === "pointer" &&
                  eventDetails.reason === "keyboard"
                ) {
                  closeStaleSubmenu(highlightedItem.current[0])
                }

                highlightedItem.current = [
                  eventDetails.index,
                  eventDetails.reason,
                ]
              }}
            >
              <AutocompletePrimitive.Input
                ref={inputRef}
                data-slot="context-menu-search-input"
                aria-label={searchLabel}
                placeholder={searchPlaceholder}
                className="mb-[var(--hui-space-2)] h-8 w-full rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--hui-space-3)] text-[var(--hui-color-foreground-base-primary)] shadow-none outline-none placeholder:text-[var(--hui-color-foreground-base-secondary)] focus-visible:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:ring-0 focus-visible:outline-none forced-colors:focus-visible:outline forced-colors:focus-visible:outline-1 forced-colors:focus-visible:outline-offset-[-2px]"
                onPointerEnter={focusInput}
                onKeyDown={(event) => {
                  if (event.key === "ArrowLeft") return
                  if (event.key === "Escape") {
                    closeHighlightedSubmenu(event)
                    return
                  }
                  if (event.key === "ArrowRight" || event.key === "Enter") {
                    openHighlightedSubmenu()
                  }
                  event.stopPropagation()
                }}
                tabIndex={-1}
              />
              <AutocompletePrimitive.List
                ref={listRef}
                data-slot="context-menu-search-list"
              >
                {children}
              </AutocompletePrimitive.List>
            </AutocompletePrimitive.Root>
          ) : (
            children
          )}
        </ContextMenuPrimitive.Popup>
      </ContextMenuPrimitive.Positioner>
    </ContextMenuPrimitive.Portal>
  )
}
function ContextMenuSubContent(props: ContextMenuContentProps) {
  return <ContextMenuContent sideOffset={2} alignOffset={-4} {...props} />
}

export {
  ContextMenu,
  ContextMenuContent,
  ContextMenuEmptyState,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
}

export type {
  AutocompleteContextMenuRootProps,
  AutocompleteContextMenuSubProps,
  ContextMenuContentProps,
  ContextMenuGroupProps,
  ContextMenuItemProps,
  ContextMenuLabelProps,
  ContextMenuRootProps,
  ContextMenuSeparatorProps,
  ContextMenuSubProps,
  ContextMenuSubTriggerProps,
  ContextMenuTriggerProps,
  NormalContextMenuRootProps,
  NormalContextMenuSubProps,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/ui/context-menu";
```

```tsx
<ContextMenu>
  <ContextMenuTrigger tabIndex={0}>
    Right-click this document
  </ContextMenuTrigger>
  <ContextMenuContent>
    <ContextMenuGroup>
      <ContextMenuLabel>Document</ContextMenuLabel>
      <ContextMenuItem>Rename</ContextMenuItem>
      <ContextMenuItem>Duplicate</ContextMenuItem>
    </ContextMenuGroup>
    <ContextMenuSeparator />
    <ContextMenuSub>
      <ContextMenuSubTrigger>Move to</ContextMenuSubTrigger>
      <ContextMenuSubContent>
        <ContextMenuItem>Projects</ContextMenuItem>
        <ContextMenuItem>Archive</ContextMenuItem>
      </ContextMenuSubContent>
    </ContextMenuSub>
  </ContextMenuContent>
</ContextMenu>
```

## Don't do this [#dont-do-this]

### An unfocusable trigger region [#an-unfocusable-trigger-region]

```tsx
// Bad
<ContextMenuTrigger className="...">
  {/* canvas surface */}
</ContextMenuTrigger>
```

```tsx
// Good
<ContextMenuTrigger className="..." tabIndex={0}>
  Right-click or press Shift+F10 for actions
</ContextMenuTrigger>
```

The trigger renders a `<div>`, which sits outside the tab order by default. Without `tabIndex`, keyboard users can never open the menu — no amount of arrow-key support downstream helps if the entry point is unreachable. Focusable regions get a visible ring automatically.

### Actions reachable only by right-click [#actions-reachable-only-by-right-click]

```tsx
// Bad
<div className="toolbar">
  <Button>Rename</Button>
  {/* Delete exists only in the context menu */}
</div>
```

```tsx
// Good
<div className="toolbar">
  <Button>Rename</Button>
  <Button variant="destructive-outline">Delete</Button>
</div>
```

A hidden right-click path hides the action from everyone who has not been told about it — touch users, screen-reader users working linearly, and anyone who simply never tries. Duplicate essential actions in a visible control and let the context menu shortcut them.

### Destroying data on a single click [#destroying-data-on-a-single-click]

```tsx
// Bad
<ContextMenuItem variant="destructive" onClick={destroyProject}>
  Delete project
</ContextMenuItem>
```

```tsx
// Good
<ContextMenuItem variant="destructive" onClick={() => setConfirmOpen(true)}>
  Delete project…
</ContextMenuItem>

<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
  {/* States what is lost; Cancel starts focused */}
</AlertDialog>
```

The destructive variant colors the item red; it does not add safety. A slip from Rename to Delete one row away should not end a project irreversibly. Route irreversible actions through a confirmation Dialog that names the consequence and starts on the safe choice.

## Examples [#examples]

The first example shows grouped actions, a nested destination menu, destructive styling, and visible feedback after selection. The second adds inline search for a longer command list.

### Nested actions [#nested-actions]

```tsx
"use client"

import * as React from "react"
import {
  Archive as ArchiveIcon,
  Copy as CopyIcon,
  Folder as FolderIcon,
  Pencil as PencilIcon,
  Trash as TrashIcon,
} from "honestui/icons"

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuSeparator,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
} from "@/components/honest-ui/ui/context-menu"

export function ContextMenuDemo() {
  const [lastAction, setLastAction] = React.useState("No action selected")

  return (
    <div className="w-full max-w-md space-y-3">
      <ContextMenu>
        <ContextMenuTrigger
          className="flex min-h-48 w-full items-center justify-center rounded-sm border border-dashed border-border bg-muted/30 p-8 text-center text-sm text-muted-foreground"
          tabIndex={0}
        >
          <span>
            Right-click or long-press this area.
            <span className="mt-1 block text-xs">
              With a keyboard, focus it and press Shift+F10.
            </span>
          </span>
        </ContextMenuTrigger>
        <ContextMenuContent>
          <ContextMenuGroup>
            <ContextMenuLabel>Document</ContextMenuLabel>
            <ContextMenuItem
              leadingIcon={<CopyIcon />}
              onClick={() => setLastAction("Copied document link")}
            >
              Copy link
            </ContextMenuItem>
            <ContextMenuItem
              leadingIcon={<PencilIcon />}
              onClick={() => setLastAction("Rename selected")}
            >
              Rename
            </ContextMenuItem>
          </ContextMenuGroup>
          <ContextMenuSeparator />
          <ContextMenuSub>
            <ContextMenuSubTrigger leadingIcon={<FolderIcon />}>
              Move to
            </ContextMenuSubTrigger>
            <ContextMenuSubContent>
              <ContextMenuItem
                onClick={() => setLastAction("Moved to Projects")}
              >
                Projects
              </ContextMenuItem>
              <ContextMenuItem
                onClick={() => setLastAction("Moved to Research")}
              >
                Research
              </ContextMenuItem>
              <ContextMenuItem
                leadingIcon={<ArchiveIcon />}
                onClick={() => setLastAction("Moved to Archive")}
              >
                Archive
              </ContextMenuItem>
            </ContextMenuSubContent>
          </ContextMenuSub>
          <ContextMenuSeparator />
          <ContextMenuItem
            leadingIcon={<TrashIcon />}
            variant="destructive"
            onClick={() => setLastAction("Delete selected")}
          >
            Delete
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
      <p className="text-center text-xs text-muted-foreground" aria-live="polite">
        {lastAction}
      </p>
    </div>
  )
}

```

### Searchable commands [#searchable-commands]

Filtering collapses a long command list to matching items; the empty state appears only when nothing matches.

```tsx
"use client"

import * as React from "react"

import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuEmptyState,
  ContextMenuItem,
  ContextMenuTrigger,
} from "@/components/honest-ui/ui/context-menu"

export function ContextMenuSearch() {
  const [lastAction, setLastAction] = React.useState("No command selected")

  return (
    <div className="w-full max-w-md space-y-3">
      <ContextMenu autocomplete>
        <ContextMenuTrigger
          className="flex min-h-48 w-full items-center justify-center rounded-sm border border-dashed border-border bg-muted/30 p-8 text-center text-sm text-muted-foreground"
          tabIndex={0}
        >
          <span>
            Open the context menu to search commands.
            <span className="mt-1 block text-xs">
              Right-click, long-press, or press Shift+F10.
            </span>
          </span>
        </ContextMenuTrigger>
        <ContextMenuContent
          searchLabel="Search document commands"
          searchPlaceholder="Search commands..."
        >
          <ContextMenuItem
            value="copy document link"
            onClick={() => setLastAction("Copied document link")}
          >
            Copy link
          </ContextMenuItem>
          <ContextMenuItem
            value="rename document"
            onClick={() => setLastAction("Rename selected")}
          >
            Rename
          </ContextMenuItem>
          <ContextMenuItem
            value="duplicate document"
            onClick={() => setLastAction("Duplicated document")}
          >
            Duplicate
          </ContextMenuItem>
          <ContextMenuItem
            value="download export"
            onClick={() => setLastAction("Download selected")}
          >
            Download
          </ContextMenuItem>
          <ContextMenuItem
            value="archive document"
            onClick={() => setLastAction("Archive selected")}
          >
            Archive
          </ContextMenuItem>
          <ContextMenuEmptyState>No matching commands.</ContextMenuEmptyState>
        </ContextMenuContent>
      </ContextMenu>
      <p className="text-center text-xs text-muted-foreground" aria-live="polite">
        {lastAction}
      </p>
    </div>
  )
}

```

## API reference [#api-reference]

`ContextMenu` accepts the matching Base UI Context Menu root props plus optional search props:

| Prop                               | Values           | Default  |
| ---------------------------------- | ---------------- | -------- |
| `autocomplete`                     | boolean          | `false`  |
| `autocompleteMode`                 | `auto`, `manual` | `auto`   |
| `inputValue` / `defaultInputValue` | string           | — / `""` |
| `onInputValueChange`               | function         | —        |

`ContextMenuContent` accepts popup props, positioning shortcuts (`sideOffset`, `align`, `alignOffset`), plus `searchLabel` and `searchPlaceholder` for the filter input's accessible label and placeholder.

Items accept Base UI item props plus `value`, `leadingIcon`, `trailingIcon`, `inset`, and `variant`. The `value` supplies searchable text when it should differ from the visible item label. In automatic filtering mode, non-matching items are removed rather than hidden, so group wrappers and separators disappear with them. See the [Base UI Context Menu API](https://base-ui.com/react/components/context-menu#api-reference) for the underlying open-state, event, positioning, and interaction details.
