# Context Menu

> Pointer-anchored action menu that opens at the right-click position, owning open state, popper placement, and layered dismissal while you own the visuals.

Source: https://docs.astra-void.xyz/lattice-ui/components/context-menu/

`@lattice-ui/react-context-menu` · Feature limited · import `ContextMenu` · depends on `runtime`, `layer`, `motion`, `popper`

Context Menu is the primitive for actions that belong to a specific object rather than to a button: right-click a slot, a plot, a player name, and get a menu at the pointer. It differs from [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) in one decisive way — the menu is anchored to **where you clicked**, not to the element you clicked. The trigger is a region, not a button.

Reach for Context Menu when the action list belongs to a **region of the screen**, when the menu should appear **under the pointer**, and when a **secondary (right) click** is the natural way to ask for it. Reach for [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) instead when a visible button opens the list, or when gamepad and keyboard users must be able to move through the items.

> **Pointer-driven only**
>
> Context Menu opens on `MouseButton2` and tracks hover on its items (exposed through `useContextMenuItemContext`, for you to render). Unlike Menu, its items do **not** register with the focus manager: there is no automatic focus of the first item, no `Up`/`Down` movement, and no focus restore on close. On a gamepad-first or keyboard-first surface, use [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md).

The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. Right-click inside the card to open the menu at the pointer.

_Interactive preview._

## Import

```ts
import { ContextMenu } from "@lattice-ui/react-context-menu";
```

## Anatomy

`Root`, `Trigger`, `Portal`, and `Content` form the working menu; `Item` makes it useful, and `Group`, `Label`, and `Separator` structure longer lists. The part names match Menu's, so moving between the two is mostly a matter of swapping the namespace.

```tsx title="Context Menu anatomy"
<ContextMenu.Root>
  <ContextMenu.Trigger />
  <ContextMenu.Portal>
    <ContextMenu.Content>
      <ContextMenu.Label />
      <ContextMenu.Group>
        <ContextMenu.Item />
      </ContextMenu.Group>
      <ContextMenu.Separator />
      <ContextMenu.Item />
    </ContextMenu.Content>
  </ContextMenu.Portal>
</ContextMenu.Root>
```

| Part | Required | Responsibility |
| --- | --- | --- |
| `ContextMenu.Root` | yes | Owns open state and the pointer position the menu opens at. |
| `ContextMenu.Trigger` | yes | The region that listens for a secondary click and reports where it happened. |
| `ContextMenu.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. |
| `ContextMenu.Content` | yes | The pointer-anchored, dismissable surface. |
| `ContextMenu.Item` | yes | A clickable action that emits `onSelect` and closes the menu. |
| `ContextMenu.Group` | no | A container that groups related items. Supply the layout yourself. |
| `ContextMenu.Label` | no | A non-interactive heading for a group or section. |
| `ContextMenu.Separator` | no | A thin divider between items or groups. |

## Examples

### Basic context menu

An uncontrolled root, a trigger region, and a few items. Right-clicking anywhere inside the trigger opens the menu at the pointer; selecting an item runs its `onSelect` and closes it.

Every part renders unstyled, so this example supplies all of it: a size and color for the trigger, a layout and surface for the content, and a size plus label for each item. Nothing here is optional decoration — without it the menu opens and works, but draws nothing.

```tsx title="BasicContextMenu.tsx"
import { ContextMenu } from "@lattice-ui/react-context-menu";

const ITEMS = [
  { label: "Rename", action: () => print("rename") },
  { label: "Duplicate", action: () => print("duplicate") },
  { label: "Delete", action: () => print("delete") },
];

export function BasicContextMenu() {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger
        BackgroundColor3={Color3.fromRGB(32, 36, 46)}
        Size={UDim2.fromOffset(280, 160)}
        Text="Right-click here"
        TextColor3={Color3.fromRGB(150, 158, 176)}
      />

      <ContextMenu.Portal>
        <ContextMenu.Content BackgroundColor3={Color3.fromRGB(28, 31, 40)}>
          <uicorner CornerRadius={new UDim(0, 8)} />
          <uipadding
            PaddingBottom={new UDim(0, 4)}
            PaddingTop={new UDim(0, 4)}
          />
          <uilistlayout FillDirection={Enum.FillDirection.Vertical} />

          {ITEMS.map((item) => (
            <ContextMenu.Item
              key={item.label}
              onSelect={item.action}
              Size={UDim2.fromOffset(220, 34)}
              Text={item.label}
              TextColor3={Color3.fromRGB(236, 241, 249)}
              TextXAlignment={Enum.TextXAlignment.Left}
            >
              <uipadding PaddingLeft={new UDim(0, 10)} />
            </ContextMenu.Item>
          ))}
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

> **Hover is tracked, not drawn**
>
> `ContextMenu.Item` still follows the pointer, but since 0.7.0 it no longer paints a highlight. Read the state with `useContextMenuItemContext()` and render it yourself — see [Highlighting items](#highlighting-items) below.

> **Uncontrolled by default**
>
> Omit `open`/`onOpenChange` and the root owns its state, starting from `defaultOpen` (closed by default). The pointer position is always owned by the root — even when you control `open` yourself, the anchor comes from the last secondary click on the trigger.

### A real trigger region

`asChild` merges the secondary-click listener onto your own element, which is the normal way to use this primitive: the trigger is the thing the actions belong to. The child keeps all of its own props and handlers — the slot only adds `InputBegan` and `Active` — so a left-click `Activated` on the same element still does whatever it did before.

```tsx title="PlotContextMenu.tsx"
import { ContextMenu } from "@lattice-ui/react-context-menu";

export function PlotContextMenu(props: { plotName: string; onSelect: () => void }) {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger asChild>
        <textbutton
          Size={UDim2.fromOffset(320, 200)}
          Text={props.plotName}
          Event={{ Activated: props.onSelect }}
        />
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(200, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />
            <uicorner CornerRadius={new UDim(0, 8)} />

            <ContextMenu.Item onSelect={() => print("build")}>
              <textbutton Text="Build here" />
            </ContextMenu.Item>
            <ContextMenu.Item onSelect={() => print("clear")}>
              <textbutton Text="Clear plot" />
            </ContextMenu.Item>
          </frame>
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

### Groups, labels, and separators

`Group` wraps related items in a 220-wide vertical-layout frame, `Label` puts a muted, non-interactive heading above them, and `Separator` draws a 1px divider. None of them are interactive — they exist to give a long list structure.

```tsx title="InventorySlotContextMenu.tsx"
import { ContextMenu } from "@lattice-ui/react-context-menu";

export function InventorySlotContextMenu(props: { icon: string }) {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger asChild>
        <imagebutton Image={props.icon} Size={UDim2.fromOffset(64, 64)} />
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(220, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />
            <uicorner CornerRadius={new UDim(0, 8)} />

            <ContextMenu.Label asChild>
              <textlabel
                BackgroundTransparency={1}
                Size={UDim2.fromOffset(220, 24)}
                Text="Equipment"
                TextColor3={Color3.fromRGB(162, 173, 191)}
              />
            </ContextMenu.Label>
            <ContextMenu.Group>
              <ContextMenu.Item onSelect={() => print("equip")}>
                <textbutton Text="Equip" />
              </ContextMenu.Item>
              <ContextMenu.Item onSelect={() => print("inspect")}>
                <textbutton Text="Inspect" />
              </ContextMenu.Item>
            </ContextMenu.Group>

            <ContextMenu.Separator />

            <ContextMenu.Item onSelect={() => print("drop")}>
              <textbutton Text="Drop" TextColor3={Color3.fromRGB(244, 120, 120)} />
            </ContextMenu.Item>
          </frame>
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

### Placement tuning

`ContextMenu.Content` takes the same popper options as the other anchored primitives, but the anchor is a zero-height virtual frame at the pointer, as wide as the measured content. That is what makes the default `placement="bottom"` drop the menu's top-left corner at the cursor, the way a desktop context menu behaves. Change `placement` when you want it to grow the other way — near the bottom of the screen the popper flips it for you regardless.

```tsx title="MinimapContextMenu.tsx"
import { ContextMenu } from "@lattice-ui/react-context-menu";

export function MinimapContextMenu() {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger asChild>
        <imagebutton Image="rbxassetid://0" Size={UDim2.fromOffset(180, 180)} />
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content placement="bottom" sideOffset={2} collisionPadding={16}>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(190, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            <ContextMenu.Item onSelect={() => print("ping")}>
              <textbutton Text="Ping location" />
            </ContextMenu.Item>
            <ContextMenu.Item onSelect={() => print("waypoint")}>
              <textbutton Text="Set waypoint" />
            </ContextMenu.Item>
          </frame>
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

### Highlighting items

The item tracks hover for you but does not paint it. `useContextMenuItemContext()` returns `{ highlighted, disabled }` — `highlighted` is already false while disabled, so one branch covers both.

Read it from a component rendered *inside* the item, since that is where the context lives:

```tsx title="ContextMenuRow.tsx"
import { ContextMenu, useContextMenuItemContext } from "@lattice-ui/react-context-menu";

function RowSurface(props: { label: string }) {
  const { highlighted, disabled } = useContextMenuItemContext();

  return (
    <frame
      BackgroundColor3={Color3.fromRGB(64, 84, 138)}
      BackgroundTransparency={highlighted ? 0 : 1}
      BorderSizePixel={0}
      Size={UDim2.fromScale(1, 1)}
    >
      <uicorner CornerRadius={new UDim(0, 4)} />
      <textlabel
        BackgroundTransparency={1}
        Size={UDim2.fromScale(1, 1)}
        Text={props.label}
        TextColor3={disabled ? Color3.fromRGB(110, 116, 132) : Color3.fromRGB(236, 241, 249)}
        TextXAlignment={Enum.TextXAlignment.Left}
      />
    </frame>
  );
}

export function ContextMenuRow(props: { label: string; disabled?: boolean }) {
  return (
    <ContextMenu.Item disabled={props.disabled} Size={UDim2.fromOffset(220, 34)}>
      <RowSurface label={props.label} />
    </ContextMenu.Item>
  );
}
```

Because the highlight is now yours, you also choose whether it animates. Wrap the transparency in a response motion if you want the old eased feel.

### Disabled items and staying open

`disabled` on an item blocks activation and clears its highlight state. `onSelect` receives a `ContextMenuSelectEvent`; calling `event.preventDefault()` marks it default-prevented and the item skips the automatic close — the one thing the default behavior does — so the menu stays open. That makes toggle-style items possible.

```tsx title="MarkerContextMenu.tsx"
import { useState } from "@rbxts/react";
import { ContextMenu } from "@lattice-ui/react-context-menu";

export function MarkerContextMenu(props: { canDelete: boolean }) {
  const [pinned, setPinned] = useState(false);

  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger asChild>
        <textbutton Size={UDim2.fromOffset(240, 120)} Text="Marker" />
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content>
          <frame
            AutomaticSize={Enum.AutomaticSize.Y}
            BackgroundColor3={Color3.fromRGB(28, 32, 42)}
            Size={UDim2.fromOffset(200, 0)}
          >
            <uilistlayout Padding={new UDim(0, 2)} SortOrder={Enum.SortOrder.LayoutOrder} />

            {/* Stays open so you can see the state flip. */}
            <ContextMenu.Item
              onSelect={(event) => {
                event.preventDefault();
                setPinned(!pinned);
              }}
            >
              <textbutton Text={pinned ? "Unpin" : "Pin"} />
            </ContextMenu.Item>

            <ContextMenu.Separator />

            <ContextMenu.Item disabled={!props.canDelete} onSelect={() => print("delete")}>
              <textbutton Text="Delete" />
            </ContextMenu.Item>
          </frame>
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

### Controlled open state

Pass `open` and `onOpenChange` when something outside the menu needs to close it — a round ending, a selection being cleared, a different panel taking over. The trigger still owns *where* the menu appears, so controlling `open` does not mean you have to supply a position.

```tsx title="ControlledContextMenu.tsx"
import { useEffect, useState } from "@rbxts/react";
import { ContextMenu } from "@lattice-ui/react-context-menu";

export function ControlledContextMenu(props: { editable: boolean }) {
  const [open, setOpen] = useState(false);

  // Leaving edit mode should take the menu with it.
  useEffect(() => {
    if (!props.editable) {
      setOpen(false);
    }
  }, [props.editable]);

  return (
    <ContextMenu.Root open={open} onOpenChange={setOpen}>
      <ContextMenu.Trigger disabled={!props.editable} asChild>
        <textbutton Size={UDim2.fromOffset(320, 200)} Text="Canvas" />
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content>
          <ContextMenu.Item onSelect={() => print("cut")} />
          <ContextMenu.Item onSelect={() => print("paste")} />
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}
```

## How it behaves

### Open state and the anchor

`ContextMenu.Root` is controllable on `open`/`onOpenChange`, with `defaultOpen` for uncontrolled usage (defaulting to closed). `ContextMenu.Trigger` watches `InputBegan` and reacts only to `Enum.UserInputType.MouseButton2`: it converts the raw pointer position into the inset-adjusted space that `GuiObject.AbsolutePosition` uses, stores it on the root, and opens the menu. That stored position survives until the next secondary click, so a controlled root can reopen the menu at the same spot.

A `disabled` trigger ignores the secondary click entirely and never updates the stored position.

### Positioning

`ContextMenu.Content` mounts an invisible virtual anchor at the stored pointer position and hands it to the shared popper machinery. The anchor has **zero height and the measured content's width**, which is what makes the resolved placement land the menu's top-left corner at the cursor instead of centering it under the pointer.

From there it behaves like every other anchored surface: the requested `placement` (default `"bottom"`) is a preference, and on collision the popper tries the opposite side, then the orthogonal sides, then clamps the best candidate inside the viewport with `collisionPadding` (default `8`) kept from every edge. `sideOffset` and `alignOffset` shift the result. Until the first measurement completes the content is parked off-screen, so it never flashes at the wrong position.

### Items and activation

`ContextMenu.Item` activates on `Activated` and builds a `ContextMenuSelectEvent` (`{ defaultPrevented, preventDefault() }`) for `onSelect`; if the event is not default-prevented, the item closes the menu. A disabled item ignores activation.

The item renders an unstyled `textbutton`. It tracks hover through `MouseEnter`/`MouseLeave` and exposes the result as `useContextMenuItemContext().highlighted`, but draws nothing itself — rendering the highlight is yours, under `asChild` or not.

### Dismissal

`ContextMenu.Content` participates in dismissable-layer behavior: only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a time. Context Menu is **modal by default**, so a full-screen blocker stops interaction behind the surface and an outside press dismisses the menu. Before dismissal, `onPointerDownOutside` fires for outside pointer presses and `onInteractOutside` fires for the interaction in general; both receive a `LayerInteractEvent` (`{ originalEvent, defaultPrevented, preventDefault() }`), and calling `preventDefault()` in either handler vetoes the dismissal while still letting you observe the interaction.

### Motion and presence

`ContextMenu.Content` runs no motion of its own. Pass a `transition` to animate it; `createPopperEntranceRecipe(placement)` from `@lattice-ui/react-motion` matches the `frame` the content renders, and taking the **resolved** placement makes the motion originate from the side the menu actually landed on — so a menu the popper flipped above the pointer animates from above. `forceMount` keeps the content mounted through its exit. The content wrapper is an automatically-sized `frame`, so your surface defines the measured size.

> **No ordered movement**
>
> `ContextMenu.Item` does not register a focus node. There is no automatic focus on open, no `Up`/`Down` movement between items, and no focus restore on close — `modal` here means "blocks pointer interaction behind the surface", not "traps selection". If your surface has to be operable without a mouse, build it with [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) instead.

> **The item highlight is yours to render**
>
> Before 0.7.0 `ContextMenu.Item` animated your element's `BackgroundColor3` between fixed hover colors with no opt-out. It no longer touches color at all — it only reports `highlighted` through `useContextMenuItemContext()`. Set the background yourself and it will stay put.

## API reference

### ContextMenu.Root

| Prop | Type | Description |
| --- | --- | --- |
| `open` | `boolean` | Controlled open state. Pair with onOpenChange. |
| `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. |
| `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes. |
| `modal` | `boolean` | When true, blocks pointer interaction behind the surface with a full-screen blocker. Defaults to true. |
| `children` | `React.ReactNode` | The context menu parts. |

### ContextMenu.Trigger

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the secondary-click listener onto the single child element instead of the textbutton the part renders. |
| `disabled` | `boolean` | Ignores the secondary click, so the menu never opens from this region. |
| `children` | `React.ReactElement` | The element to render. Required when asChild is set. |

### ContextMenu.Portal

| Prop | Type | Description |
| --- | --- | --- |
| `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. |
| `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. |
| `children` | `React.ReactNode` | The content part. |

### ContextMenu.Content

| Prop | Type | Description |
| --- | --- | --- |
| `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on, relative to the pointer anchor. Falls back to the opposite side, then the orthogonal sides, on collision. Defaults to "bottom". |
| `sideOffset` | `number` | Gap in pixels between the pointer anchor and the content. Defaults to 0. |
| `alignOffset` | `number` | Shift in pixels along the anchor's cross axis. Defaults to 0. |
| `collisionPadding` | `number` | Minimum distance in pixels to keep from every screen edge when resolving and clamping placement. Defaults to 8. |
| `asChild` | `boolean` | Render the single child element inside the positioned frame instead of the part's own children. |
| `forceMount` | `boolean` | Keeps the content mounted while exit motion runs, instead of unmounting on close. |
| `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. |
| `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. Call event.preventDefault() to veto the dismissal. |
| `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any outside interaction, before dismissal. Call event.preventDefault() to veto the dismissal. |
| `children` | `React.ReactNode` | The menu contents. |

### ContextMenu.Item

Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error. The primitive owns `Active` and `Selectable` derived from disabled, so values you pass for those are ignored.

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. |
| `disabled` | `boolean` | Prevents activation and forces highlighted to false. |
| `onSelect` | `(event: ContextMenuSelectEvent) => void` | Called on activation. Call event.preventDefault() to keep the menu open. |
| `children` | `React.ReactNode` | The item contents. Must be a single element when asChild is set. |
| `…TextButton props` | `Partial<WritableInstanceProperties<TextButton>>` | Forwarded onto the rendered textbutton and type-checked against it. Active and Selectable are owned by the primitive, derived from disabled. |

### ContextMenu.Group

Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error.

> **Supply the layout yourself**
>
> As of 0.7.0 `ContextMenu.Group` renders a plain frame: no `UIListLayout`, no `AutomaticSize`. It was one of the last two primitives holding an opinion about how children lay out. Add a `uilistlayout` child, as `Select.Group` and `Combobox.Group` have always required.

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the group onto the single child element instead of the frame the part renders. |
| `children` | `React.ReactNode` | The grouped items, plus the layout that arranges them. Must be a single element when asChild is set. |
| `…Frame props` | `Partial<WritableInstanceProperties<Frame>>` | Forwarded onto the rendered frame and type-checked against it. |

### ContextMenu.Label

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the label onto the single child element instead of the textlabel the part renders. |
| `children` | `React.ReactNode` | The label contents. Must be a single element when asChild is set. |
| `…TextLabel props` | `Partial<WritableInstanceProperties<TextLabel>>` | Forwarded onto the rendered textlabel and type-checked against it. Pass Text and TextColor3 here — the part renders no copy of its own. |

### ContextMenu.Separator

| Prop | Type | Description |
| --- | --- | --- |
| `asChild` | `boolean` | Merge the separator onto the single child element instead of the frame the part renders. |
| `children` | `React.ReactNode` | Rendered inside the separator. Must be a single element when asChild is set. |
| `…Frame props` | `Partial<WritableInstanceProperties<Frame>>` | Forwarded onto the rendered frame and type-checked against it. Give it a Size and BackgroundColor3 — it draws nothing on its own. |

## Related

- [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md)
- [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md)
- [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md)
- [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
